diff --git a/.agents/building-and-testing.md b/.agents/building-and-testing.md
index 021d555ec993..eee5123ac82c 100644
--- a/.agents/building-and-testing.md
+++ b/.agents/building-and-testing.md
@@ -45,3 +45,23 @@ Rules (both gates):
- **Don't weaken the gate:** never hand-lower a baseline or widen a tolerance to turn a red gate green. The ratchet only moves up.
- If a change drops coverage, **add tests** (sort `coverage-summary.json` by line% ascending to find untested code) rather than editing the baseline. When coverage legitimately rises, commit the regenerated baseline (`make test-coverage-baseline` / `test-ui-coverage-baseline`).
- The Go gate is **strict — no tolerance**; `covermode=atomic` keeps it deterministic. The UI gate keeps a small tolerance only because its e2e coverage isn't.
+
+## Distributed-mode test suites
+
+Two suites cover distributed mode (frontend replicas, worker nodes, PostgreSQL, NATS), split by a Ginkgo label:
+
+- `make test-e2e-distributed` runs `Distributed && !VLLMMultinode && !Cluster` over `./tests/e2e/distributed` recursively. Services are wired directly into the test binary. ~240 specs, ~75s.
+- `make test-e2e-cluster` runs `Cluster` and spawns real `local-ai` child processes through the `tests/e2e/distributed/cluster` helper package. 6 specs, about 8m30s measured over three consecutive runs (509.1s / 509.8s / 512.3s, so 8m29s to 8m32s).
+
+Both jobs live in `.github/workflows/tests-e2e-distributed.yml`, with `timeout-minutes: 45` each. They trigger on pull requests *and* on every push to `master`; the `paths-ignore` filter (see [.agents/ci-caching.md](ci-caching.md)) sits on the pull-request trigger only, so a master push always runs both. They are advisory only because `master` carries no branch protection, which is a repository setting and not a YAML key: `continue-on-error: true` would flip the run's *conclusion* to success and hide the failure, so it is not used.
+
+- **Containers are suite-scoped, not spec-scoped.** `SetupInfra` used to start a PostgreSQL (~10s) and a NATS (~3.5s) per spec. Across the 213 specs behind it that was roughly **48 minutes of pure container startup per run**, which is why this suite was never in CI. (213 rather than the ~240 above: the larger number is everything the label filter selects, the smaller one is just the specs that call `SetupInfra`.) Containers now start once in `BeforeSuite` and each spec gets its own database via `CREATE DATABASE` (~67ms), which is what the `dbName` argument was always describing. Adding a spec needs no change: call `SetupInfra("some-name")` as before, the name is a prefix and a counter keeps it unique.
+- **Consequence for new specs:** the NATS bus is now *shared* within a Ginkgo process, so a wildcard subscriber can observe another spec's traffic. Filter assertions on an identifier your spec owns (a node ID, a job ID) instead of counting everything on `jobs.*.progress`, and verify the spec with `--randomize-all`.
+- **`BeforeSuite`, not `SynchronizedBeforeSuite`.** Under `ginkgo -p` each process then gets its own container pair, keeping NATS subjects isolated per process. A single shared NATS across parallel processes would let specs on different processes see each other's messages on the same subject.
+- **The label split.** The 8 argument-validation specs under `tests/e2e/distributed/cluster/` carry `Label("Distributed")` only, on purpose: they need no binary, no PostgreSQL and no NATS, so they belong in the fast job. That is why `test-e2e-distributed` keeps `-r` (it must reach the subpackage) and `test-e2e-cluster` deliberately does **not** (the subpackage is out of its scope).
+- **`--fail-on-empty` is load-bearing on both targets.** Ginkgo exits 0 when a label filter selects nothing, so without it a refactor that renames or drops `Label("Cluster")` leaves the target reporting "Test Suite Passed" having started no cluster at all. `LOCALAI_E2E_REQUIRE_BINARIES` does not cover this case: it only fires inside a spec that is actually running.
+- **The binary gate.** `localAIBinary()` and `mockBackendBinary()` **fail** rather than skip when `CI` is set, or when `LOCALAI_E2E_REQUIRE_BINARIES` is truthy; `LOCALAI_E2E_REQUIRE_BINARIES=0` (also `off`, `no`, `n`, `disabled`, and anything `strconv.ParseBool` reads as false) forces skipping even under CI. **Any value that parses as neither reads as ON**, not off: setting the variable to something meaningless means someone meant to turn the gate on, and reading it as false would quietly restore the silent skip the flag exists to remove. The whole polarity is deliberate, because in CI a skipped cluster spec is indistinguishable from a passing one: Ginkgo exits 0 on skips. Locally a missing binary still just skips, since `CI` is unset in an ordinary shell.
+- **Flake budget: no retries at all.** `--flake-attempts` is *total attempts*, not retries (ginkgo v2.29.0 `internal/group.go` sets `maxAttempts = FlakeAttempts` and loops `attempt < maxAttempts`; the flag's own usage string reads "0 - failed tests are not retried"). `DISTRIBUTED_TEST_FLAKES` defaults to **1**, so each spec runs once and a failure is a failure, and `test-e2e-cluster` pins `--flake-attempts 1` outright rather than reading the variable. The repo-wide `TEST_FLAKES=5` means up to five attempts, so up to four retries. These suites exist to surface nondeterminism, and a retry converts exactly that signal into a green run. Raise it locally when bisecting something unrelated, not in the Makefile.
+- **Coverage:** `tests/e2e/distributed` is excluded from the coverage roots (`COVERAGE_E2E_ROOTS = ./tests/e2e`, run non-recursively), and so is the `cluster` helper package beneath it. Neither suite moves the baseline, so production code that these suites are the only cover for reads as **uncovered**. Unit tests for such code belong under `./core/...` with `testutil.SetupTestDB()`.
+- **The cluster job builds against a stubbed React UI.** `core/http/react-ui/dist` is gitignored and built by Node, so the workflow writes a one-line `index.html` there to satisfy the `//go:embed react-ui/dist/*` in `core/http/app.go` and skips a full Node and Vite install. That holds only while the suite drives the HTTP API and never the UI, which has its own e2e suite. A spec that ever asserts on a UI asset would pass locally, where a real `dist/` exists, and be served the stub in CI: if you write one, the stub step has to go and the real build come back.
+- **Do not shorten the cluster suite's waits.** Three of its six specs sit at ~167s each because they wait out a 60s staleness threshold plus a 15s health-check tick. That wait is what stops the assertions from passing before the system could have reacted, which was a real false green earlier on. If the job has to get faster, the levers are CI concurrency or making the thresholds configurable, not shorter waits.
diff --git a/.agents/ci-caching.md b/.agents/ci-caching.md
index 6742049e68ff..8dc243747384 100644
--- a/.agents/ci-caching.md
+++ b/.agents/ci-caching.md
@@ -153,7 +153,7 @@ This is worth more than it looks. Measured over the week to 2026-07-30, **97% of
The volume is real: 13 gallery-only PRs merged that week with 10 open at once, and 78 of the 137 PRs opened were bot-generated.
-`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20. The excluded set:
+`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20, measured before `tests-e2e-distributed.yml` (2 jobs) landed. That workflow carries the same exclusion set for the same reason: its dependency graph is 99 packages, so an allowlist of paths would silently stop guarding the moment code moved, while a diff confined to the paths below provably cannot reach it. The excluded set:
| Path | Why no image or Go build can see it |
|---|---|
@@ -192,7 +192,7 @@ What still runs, and why it has to:
Two properties this relies on:
- `paths-ignore` skips a run only when **every** changed file matches, so a PR touching the gallery *and* Go code still runs everything. That is what makes the exclusion safe rather than a hole.
-- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these four entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
+- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these five entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
### `image.yml` on master push is gated too, by a job rather than a path filter
diff --git a/.github/workflows/tests-e2e-distributed.yml b/.github/workflows/tests-e2e-distributed.yml
new file mode 100644
index 000000000000..8f17e72f169a
--- /dev/null
+++ b/.github/workflows/tests-e2e-distributed.yml
@@ -0,0 +1,204 @@
+---
+name: 'E2E Distributed Tests'
+
+on:
+ pull_request:
+ # The suite's dependency graph is 99 packages, so an allowlist of paths
+ # silently stops guarding the moment code moves. At ~75s the job is cheap
+ # enough to run unless the diff is confined to paths it provably cannot
+ # reach. See .agents/ci-caching.md.
+ paths-ignore:
+ - 'gallery/**'
+ - 'docs/**'
+ - 'examples/**'
+ - '**/*.md'
+ push:
+ branches:
+ - master
+
+concurrency:
+ group: ci-tests-e2e-distributed-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ tests-e2e-distributed:
+ runs-on: ubuntu-latest
+ # Advisory because it is deliberately not in branch protection, so a failure
+ # is a visible red X rather than a blocked merge. Promoting it to a required
+ # check is a repository-settings change, to be made once it has a track
+ # record; a heavy suite made required on day one gets disabled instead of
+ # fixed.
+ timeout-minutes: 45
+ steps:
+ - name: Clone
+ uses: actions/checkout@v7
+ with:
+ submodules: true
+ - name: Configure apt mirror on runner
+ uses: ./.github/actions/configure-apt-mirror
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.26.0'
+ cache: false
+ - name: Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential libopus-dev
+ - name: Proto Dependencies
+ run: |
+ curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
+ unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
+ rm protoc.zip
+ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
+ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
+ PATH="$PATH:$HOME/go/bin" make protogen-go
+ - name: Pre-pull test images
+ # Pulling here rather than inside the suite keeps container-start timing
+ # out of the spec timeouts and makes a registry outage read as a
+ # setup failure instead of a test failure. These two are the only images
+ # the suite needs once the testcontainers reaper is disabled below.
+ run: |
+ docker pull postgres:16-alpine
+ docker pull nats:2-alpine
+ - name: Distributed E2E
+ # TESTCONTAINERS_RYUK_DISABLED keeps the pre-pull above meaningful. The
+ # reaper exists to clean up leaked containers on a long-lived host, but
+ # this runner is ephemeral and every container dies with the VM. Leaving
+ # it enabled would pull a third, unpinned image (testcontainers/ryuk)
+ # from Docker Hub mid-suite: exactly the registry dependency the
+ # pre-pull step exists to remove.
+ env:
+ TESTCONTAINERS_RYUK_DISABLED: "true"
+ run: |
+ PATH="$PATH:$HOME/go/bin" make test-e2e-distributed
+ - name: Setup tmate session if tests fail
+ if: ${{ failure() }}
+ uses: mxschmitt/action-tmate@v3.23
+ with:
+ detached: true
+ connect-timeout-seconds: 180
+ limit-access-to-actor: true
+
+ tests-e2e-cluster:
+ runs-on: ubuntu-latest
+ # Advisory for the same reason as the job above: master has no branch
+ # protection, so a failure here is a visible red X rather than a blocked
+ # merge. That is a repository-settings property, not a YAML key. The key
+ # that looks like it says "advisory" instead flips the run's conclusion to
+ # success, which hides the failure rather than flagging it, so it appears in
+ # none of this repo's workflows and must not be added here.
+ #
+ # Separate job from tests-e2e-distributed so the fast in-process suite is
+ # not held behind a Go build of local-ai. Serial on purpose: each Ginkgo
+ # process would get its own PostgreSQL and NATS container and each spec
+ # spawns two or three local-ai children, so --procs on an unmeasured runner
+ # is a change to make with numbers, not by default.
+ #
+ # The two timeouts bound different things and are not alternatives. Ginkgo's
+ # --timeout=20m bounds the SUITE only; this job timeout must additionally
+ # cover setup, which here is the larger and more variable half: submodule
+ # checkout, apt, protoc plus two go installs plus protogen-go, a cold-cache
+ # module download (cache: false), a full go build of ./cmd/local-ai, and a
+ # separate ginkgo test compile. That build alone is ~316s of CPU, so on a
+ # 4-vCPU runner setup is realistically 8-12 minutes.
+ #
+ # 45 minutes therefore, matching the sibling job. A tighter number does not
+ # make a hang fail faster, it just moves the kill from Ginkgo, which prints
+ # which spec hung, to the runner, which prints nothing: a red job with no
+ # evidence, which is how a suite gets disabled rather than fixed.
+ #
+ # The suite itself is about 8m30s over three consecutive runs (509.1s /
+ # 509.8s / 512.3s, so 8m29s to 8m32s) on a developer box, and will be slower
+ # here. Three specs sit at ~167s each because they wait out a 60s staleness
+ # threshold plus a 15s health-check tick (HealthCheckInterval, in
+ # core/config/distributed_config.go; core/services/nodes/health.go runs the
+ # ticker on the unexported checkInterval, not one of the reconcilers). Do
+ # not shorten those windows to make this job faster: the wait is what stops
+ # the assertions from passing before the system could have reacted, which
+ # was a real false green earlier on.
+ timeout-minutes: 45
+ steps:
+ - name: Clone
+ uses: actions/checkout@v7
+ with:
+ submodules: true
+ - name: Configure apt mirror on runner
+ uses: ./.github/actions/configure-apt-mirror
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.26.0'
+ cache: false
+ - name: Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential libopus-dev
+ - name: Proto Dependencies
+ run: |
+ curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
+ unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
+ rm protoc.zip
+ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
+ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
+ PATH="$PATH:$HOME/go/bin" make protogen-go
+ - name: Stub the embedded React UI
+ # core/http/react-ui/dist is gitignored and built by Node, but this
+ # suite drives the HTTP API and never the UI, which has its own e2e
+ # suite. A single index.html satisfies the //go:embed react-ui/dist/*
+ # in core/http/app.go, so the job skips a full Node and Vite install.
+ # If a cluster spec ever asserts on a UI asset, this step must go and
+ # the real build come back: a developer box has a real dist/, so such a
+ # spec would pass locally and fail only here, or worse be served the
+ # stub and pass in both places.
+ run: |
+ mkdir -p core/http/react-ui/dist
+ printf '
stub\n' > core/http/react-ui/dist/index.html
+ - name: Build local-ai
+ # Not `make build`: that target pulls in the React UI build. The specs
+ # exec this binary directly via LOCALAI_E2E_BINARY.
+ run: |
+ PATH="$PATH:$HOME/go/bin" go build -o local-ai ./cmd/local-ai
+ - name: Pre-pull test images
+ # Same reasoning as the job above: pulling here keeps container-start
+ # timing out of the spec timeouts and makes a registry outage read as a
+ # setup failure rather than a test failure.
+ run: |
+ docker pull postgres:16-alpine
+ docker pull nats:2-alpine
+ - name: Cluster E2E
+ env:
+ LOCALAI_E2E_BINARY: ${{ github.workspace }}/local-ai
+ # Must live under the workspace so the upload step below can reach it.
+ # The harness defaults to GinkgoT().TempDir(), which lands under
+ # TMPDIR and would leave the artifact glob matching nothing.
+ LOCALAI_E2E_LOG_DIR: ${{ github.workspace }}/cluster-logs
+ # Belt and braces: the harness already fails rather than skips when CI
+ # is set, and GitHub Actions always sets CI. Stating it here means a
+ # future edit to that default cannot silently turn this job into one
+ # that passes without ever starting a cluster, since a skipped cluster
+ # spec is indistinguishable from a passing one.
+ LOCALAI_E2E_REQUIRE_BINARIES: "true"
+ # See the job above: the runner is ephemeral, so the reaper buys
+ # nothing and would pull a third, unpinned Docker Hub image mid-suite.
+ TESTCONTAINERS_RYUK_DISABLED: "true"
+ run: |
+ PATH="$PATH:$HOME/go/bin" make test-e2e-cluster
+ - name: Upload process logs
+ # The per-process logs are the only way to read a cluster failure: the
+ # Ginkgo output says which assertion failed, not what the four child
+ # processes were doing. Without this a red job is undebuggable.
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: cluster-process-logs
+ path: cluster-logs/**/*.log
+ if-no-files-found: ignore
+ retention-days: 7
+ - name: Setup tmate session if tests fail
+ if: ${{ failure() }}
+ uses: mxschmitt/action-tmate@v3.23
+ with:
+ detached: true
+ connect-timeout-seconds: 180
+ limit-access-to-actor: true
diff --git a/.golangci.yml b/.golangci.yml
index d25d1ccb4789..7a68e69a75f2 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -19,7 +19,22 @@ linters:
- staticcheck
enable:
- forbidigo
+ # gocritic is enabled for ONE checker: ruleguard, which runs the rules in
+ # hack/lint/. Every other gocritic check is off (disable-all below), so
+ # this adds no style noise; it is here purely as the gate that catches a
+ # gRPC backend wrapper written without Unwrap. See
+ # hack/lint/backend_wrappers.go for why that cannot be a compile-time
+ # assertion.
+ - gocritic
settings:
+ gocritic:
+ disable-all: true
+ enabled-checks:
+ - ruleguard
+ settings:
+ ruleguard:
+ failOn: all
+ rules: '${base-path}/hack/lint/backend_wrappers.go'
forbidigo:
forbid:
- pattern: '^t\.Errorf$'
@@ -126,3 +141,10 @@ linters:
- path: ^backend/go/whisper/sources/
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
linters: [forbidigo]
+ # Test doubles embed grpc.Backend to inherit the interface's method set
+ # over a NIL value; they decorate nothing, hold no inner client, and have
+ # no transport answer to forward. The rule targets production wrappers,
+ # which is where swallowing that answer deletes replica rows.
+ # gocritic here is only the backend-wrapper ruleguard rule.
+ - path: _test\.go$
+ linters: [gocritic]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d87db37eae63..f48c7c4a337c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -265,6 +265,37 @@ The e2e tests run LocalAI in a Docker container and exercise the API:
make test-e2e
```
+### Running distributed-mode tests
+
+Distributed mode (several frontend replicas, worker nodes, PostgreSQL and NATS) has two suites. Both bring up their PostgreSQL and NATS with testcontainers, so Docker has to be available:
+
+```bash
+make test-e2e-distributed # in-process: services wired directly into the test binary
+make test-e2e-cluster # process-level: real local-ai child processes
+```
+
+`make test-e2e-distributed` is the fast one (around 240 specs in roughly 75 seconds). It starts one PostgreSQL and one NATS for the whole run and gives each spec its own database. It runs each spec exactly once, with no retry: `DISTRIBUTED_TEST_FLAKES` defaults to 1 and feeds ginkgo's `--flake-attempts`, which counts *total attempts*, not retries. That is deliberately below the repo-wide `TEST_FLAKES=5`, because this suite exists to catch nondeterministic cluster behaviour and a retry hides exactly the failure it is meant to catch. Raise it locally when bisecting something unrelated.
+
+`make test-e2e-cluster` runs `local-ai` as real child processes, one per frontend replica and one per worker, so a spec can kill a replica and assert what the survivors do. Budget about 8m30s (measured 509.1s / 509.8s / 512.3s over three consecutive runs): three of its six specs wait out real staleness and health-check windows. It needs a built binary and the mock backend:
+
+```bash
+make build build-mock-backend
+make test-e2e-cluster
+```
+
+Two environment variables steer it:
+
+| Variable | Purpose |
+|---|---|
+| `LOCALAI_E2E_BINARY` | path to the `local-ai` binary (default: `local-ai` in the repository root) |
+| `LOCALAI_E2E_LOG_DIR` | directory for the per-process logs (default: a Ginkgo temp dir) |
+
+Set `LOCALAI_E2E_LOG_DIR` when debugging. A cluster failure is unreadable without the individual frontend and worker logs, and Ginkgo only tells you which assertion failed.
+
+A missing binary skips the cluster specs locally but fails them whenever `CI` is set, so a build problem cannot turn the CI job green without ever starting a cluster. `LOCALAI_E2E_REQUIRE_BINARIES=1` forces that failing behaviour anywhere; `LOCALAI_E2E_REQUIRE_BINARIES=0` forces the skip back on even under CI.
+
+Both suites run in `.github/workflows/tests-e2e-distributed.yml`, on pull requests and on every push to `master`. The `paths-ignore` filter is on the pull-request trigger only, so a master push always runs them.
+
### React UI tests and coverage
The React UI (`core/http/react-ui/`) is covered by Playwright e2e specs, gated by a **monotonic line-coverage ratchet** (`make test-ui-coverage-check`, run in CI). The metric is non-deterministic — a fast local box reads higher than a slow CI runner for the same code — so a small tolerance is unavoidable.
diff --git a/Makefile b/Makefile
index ebedb2c98248..e45930585049 100644
--- a/Makefile
+++ b/Makefile
@@ -340,12 +340,59 @@ run-e2e-aio: protogen-go
@echo 'Running e2e AIO tests'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e-aio
+# Total ginkgo attempts per spec for the distributed suite: --flake-attempts counts
+# attempts, not retries. Defaults to 1, so each spec runs once and is never retried,
+# unlike TEST_FLAKES=5. This suite exists to catch nondeterministic cluster behaviour,
+# and a retry hides exactly the failures it is meant to surface. Raise it locally if
+# you are bisecting something unrelated.
+DISTRIBUTED_TEST_FLAKES?=1
+
# Distributed architecture e2e (PostgreSQL + NATS via testcontainers).
# Includes NatsJWT specs (JWT-enabled NATS). Requires Docker.
# VLLMMultinode is excluded here; use test-e2e-vllm-multinode for that.
+# Cluster is excluded too and runs in test-e2e-cluster below, which needs a
+# built binary. The argument-validation specs under tests/e2e/distributed/cluster
+# carry Label("Distributed") only, so they run here and not there, on purpose.
+# -r stays because of those: they are in a subpackage this target must reach.
+# --fail-on-empty because ginkgo exits 0 when a label filter matches nothing, so
+# without it a rename of the label would turn this target into a silent no-op
+# that still reports "Test Suite Passed".
test-e2e-distributed: protogen-go
@echo 'Running distributed e2e tests (label Distributed, incl. NatsJWT)'
- $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode' --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e/distributed
+ $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode && !Cluster' --fail-on-empty --flake-attempts $(DISTRIBUTED_TEST_FLAKES) --timeout=40m -v -r ./tests/e2e/distributed
+
+# Cluster e2e: runs local-ai as real child processes (frontend replicas +
+# workers) against PostgreSQL and NATS, and kills them to assert failover.
+# Needs a built ./local-ai (or LOCALAI_E2E_BINARY) plus the mock backend.
+#
+# The argument-validation specs in tests/e2e/distributed/cluster deliberately
+# stay in test-e2e-distributed above: they need no binary, no PostgreSQL and no
+# NATS, so no -r here and that package is simply out of scope.
+#
+# --fail-on-empty is load-bearing, not tidiness. Ginkgo exits 0 when a label
+# filter selects nothing, so without it a refactor that renames or drops
+# Label("Cluster") leaves this target reporting "Test Suite Passed" having
+# started no cluster at all. LOCALAI_E2E_REQUIRE_BINARIES does not cover this:
+# it only fires inside a spec that is actually running.
+#
+# --flake-attempts is pinned to 1 rather than $(DISTRIBUTED_TEST_FLAKES), and
+# should stay there: this suite exists to catch nondeterministic cluster
+# behaviour, and a retry turns exactly that signal into a green run.
+#
+# Budget: 20 specs, measured at 800 to 830 seconds of Ginkgo time (13 to 14
+# minutes wall including the compile) on a fast developer box. It was 591 to 612
+# seconds before the phase 3 control-plane specs; those five added roughly 200
+# seconds, most of it in the two that wait out real windows rather than poll for
+# a state change (cluster.InstanceLiveness is 30s, and a departed worker cannot
+# be demoted before its reconnect grace).
+#
+# --timeout is 30m rather than 20m because of that. The margin is not slack: a
+# Ginkgo timeout kills the suite mid-spec and reports a spec name rather than a
+# cause, and 20m on a loaded CI runner was one slow health tick away from
+# turning a green suite into an unreadable red one.
+test-e2e-cluster: protogen-go build-mock-backend
+ @echo 'Running cluster e2e tests (label Cluster, real local-ai processes)'
+ $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Cluster' --fail-on-empty --flake-attempts 1 --timeout=30m -v ./tests/e2e/distributed
# vLLM multi-node DP smoke (CPU). Builds local-ai:tests and the
# cpu-vllm backend from the current working tree, then drives a
diff --git a/core/application/absence_wiring.go b/core/application/absence_wiring.go
new file mode 100644
index 000000000000..b5f3df99532e
--- /dev/null
+++ b/core/application/absence_wiring.go
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: MIT
+
+package application
+
+import (
+ "fmt"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/nodes"
+)
+
+// distributedSchedulerOptions stamps the absence wiring onto the scheduler's
+// options and returns them.
+//
+// Two assignments in a named function rather than two more fields in the
+// twenty-field literal they used to live in. The literal cannot be reached by a
+// unit spec, because the function that builds it also opens a NATS connection
+// and a database; these two lines can, and they are the two lines this whole
+// change comes down to. Losing them in the literal was silent and green.
+//
+// The grace comes from the same expression the membership loop is given
+// (Membership.SetReconnectGrace), so the window a departure is measured against
+// and the window a departure is RETAINED for cannot drift apart.
+func distributedSchedulerOptions(cfg config.DistributedConfig, presence nodes.NodePresenceReader, opts nodes.SmartRouterOptions) nodes.SmartRouterOptions {
+ opts.Presence = presence
+ opts.ReconnectGrace = cfg.ReconnectGraceOrDefault()
+ return opts
+}
+
+// requireAbsenceWiring refuses to start a distributed deployment in which
+// nothing can decide that a worker has gone away.
+//
+// Two components read absence, from one source and against one window: the
+// scheduler, which stops placing work on a departed worker, and the health
+// monitor, which stops reporting one as healthy. Each reads it through a field
+// assigned in a large construction literal in initDistributed.
+//
+// It is checked rather than assumed because losing either assignment is
+// SILENT. A scheduler with no absence source places work on workers that are
+// gone and demotes none; a health monitor with none reports a worker whose
+// tunnel died an hour ago as healthy, forever, with every request for a model
+// loaded on it failing "no route to that worker". Neither logs anything,
+// neither fails a request that would not have failed anyway, and both look
+// exactly like a fleet that is fine. Refusing to boot is the only symptom
+// either failure has, and it is the reason this is a startup error and not a
+// warning: a deployment that came up and quietly decided absence by nothing is
+// the state the tunnel work exists to remove.
+//
+// What this guard itself rests on, stated because it is a real limit: the two
+// helper functions below and above are pinned by unit specs, but the CALL to
+// this one lives in initDistributed, which opens NATS and a database and so has
+// no unit spec at all. Deleting the call, or writing a literal nil where
+// initDistributed passes the cluster registry, compiles and leaves every suite
+// in this repository green. Only tests/e2e/distributed/cluster catches it, by
+// booting the real binary: the error is returned from initDistributed and
+// aborts application startup, so a frontend so wired never comes up.
+func requireAbsenceWiring(router *nodes.SmartRouter, health *nodes.HealthMonitor) error {
+ if !router.ReadsAbsence() {
+ return fmt.Errorf("the distributed scheduler was built with no source of worker absence: it would place work on workers that have gone away and never demote one")
+ }
+ if !health.ReadsAbsence() {
+ return fmt.Errorf("the node health monitor was built with no source of worker absence: it would report a worker whose tunnel is gone as healthy indefinitely")
+ }
+ return nil
+}
diff --git a/core/application/absence_wiring_test.go b/core/application/absence_wiring_test.go
new file mode 100644
index 000000000000..5ca33122c2f6
--- /dev/null
+++ b/core/application/absence_wiring_test.go
@@ -0,0 +1,121 @@
+// SPDX-License-Identifier: MIT
+
+package application
+
+import (
+ "context"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+)
+
+// presenceStub is any source of absence. What it answers does not matter here:
+// these specs are about whether a source was wired at all, which is the one
+// property that has no other symptom.
+type presenceStub struct{}
+
+func (presenceStub) Presence(context.Context, string, time.Duration) (cluster.Presence, error) {
+ return cluster.PresenceConnected, nil
+}
+
+// The guard on the two lines that connect the absence decision to production.
+//
+// Absence is read in exactly two places, and each reads it through one field
+// assigned in a twenty-field construction literal in initDistributed. Deleting
+// either assignment compiles, passes every suite in this repository, and
+// returns the deployment to "absence is decided by nothing" without a log line.
+// That is the failure this guard exists for, and these specs are what keep the
+// guard honest: an assertion that never fails is not one.
+var _ = Describe("stamping the absence wiring onto the scheduler's options", func() {
+ It("gives the scheduler the deployment's source of absence", func() {
+ reg := presenceStub{}
+
+ opts := distributedSchedulerOptions(config.DistributedConfig{}, reg, nodes.SmartRouterOptions{})
+
+ Expect(opts.Presence).To(Equal(nodes.NodePresenceReader(reg)))
+ })
+
+ It("gives it the operator's reconnect grace", func() {
+ opts := distributedSchedulerOptions(
+ config.DistributedConfig{WorkerReconnectGrace: 4 * time.Minute}, presenceStub{}, nodes.SmartRouterOptions{})
+
+ Expect(opts.ReconnectGrace).To(Equal(4 * time.Minute))
+ })
+
+ It("falls back to the documented default when the operator set no grace", func() {
+ opts := distributedSchedulerOptions(config.DistributedConfig{}, presenceStub{}, nodes.SmartRouterOptions{})
+
+ Expect(opts.ReconnectGrace).To(Equal(config.DefaultWorkerReconnectGrace))
+ })
+
+ It("leaves every other option the caller built untouched", func() {
+ // The negative control: a stamp that rebuilt the options would drop the
+ // twenty fields the caller assembled, and the two assertions above
+ // would still pass.
+ opts := distributedSchedulerOptions(config.DistributedConfig{}, presenceStub{},
+ nodes.SmartRouterOptions{GalleriesJSON: "[]", SharedModels: true})
+
+ Expect(opts.GalleriesJSON).To(Equal("[]"))
+ Expect(opts.SharedModels).To(BeTrue())
+ })
+
+ It("produces a scheduler that reads absence", func() {
+ // The property the boot guard checks, asserted through the same call
+ // initDistributed makes.
+ router := nodes.NewSmartRouter(nil,
+ distributedSchedulerOptions(config.DistributedConfig{}, presenceStub{}, nodes.SmartRouterOptions{}))
+
+ Expect(router.ReadsAbsence()).To(BeTrue())
+ })
+})
+
+var _ = Describe("the absence wiring a distributed deployment refuses to start without", func() {
+ present := func() (*nodes.SmartRouter, *nodes.HealthMonitor) {
+ router := nodes.NewSmartRouter(nil, nodes.SmartRouterOptions{Presence: presenceStub{}})
+ health := nodes.NewHealthMonitor(nil, nil, time.Second, time.Minute, "", false, presenceStub{}, time.Minute)
+ return router, health
+ }
+
+ It("accepts a deployment where both readers have a source", func() {
+ router, health := present()
+ Expect(requireAbsenceWiring(router, health)).To(Succeed())
+ })
+
+ It("refuses a scheduler built without one, and says what it would do", func() {
+ _, health := present()
+ blind := nodes.NewSmartRouter(nil, nodes.SmartRouterOptions{})
+
+ err := requireAbsenceWiring(blind, health)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("scheduler"))
+ Expect(err.Error()).To(ContainSubstring("never demote"))
+ })
+
+ It("refuses a health monitor built without one, and says what it would do", func() {
+ router, _ := present()
+ blind := nodes.NewHealthMonitor(nil, nil, time.Second, time.Minute, "", false, nil, 0)
+
+ err := requireAbsenceWiring(router, blind)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("health monitor"))
+ Expect(err.Error()).To(ContainSubstring("healthy indefinitely"))
+ })
+
+ // Each reader is named separately on purpose. One guard covering "at least
+ // one of them" would accept a deployment that had lost the other, and the
+ // two failures are different: the scheduler's places work on a dead worker,
+ // the monitor's leaves it listed healthy while its models are unreachable.
+ It("names the scheduler and the health monitor as separate requirements", func() {
+ blindRouter := nodes.NewSmartRouter(nil, nodes.SmartRouterOptions{})
+ blindHealth := nodes.NewHealthMonitor(nil, nil, time.Second, time.Minute, "", false, nil, 0)
+ router, health := present()
+
+ Expect(requireAbsenceWiring(blindRouter, health)).ToNot(Succeed())
+ Expect(requireAbsenceWiring(router, blindHealth)).ToNot(Succeed())
+ })
+})
diff --git a/core/application/distributed.go b/core/application/distributed.go
index b7dc0bf91351..c814af22488c 100644
--- a/core/application/distributed.go
+++ b/core/application/distributed.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
+ "net"
+ "strconv"
"strings"
"sync"
"time"
@@ -12,12 +14,14 @@ import (
"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/nodes"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
"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"
@@ -43,6 +47,35 @@ type DistributedServices struct {
Unloader *nodes.RemoteUnloaderAdapter
ModelCleanup *nodes.ModelCleanupService
+ // 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
+
shutdownOnce sync.Once
}
@@ -53,6 +86,22 @@ func (ds *DistributedServices) Shutdown() {
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()
}
@@ -162,6 +211,100 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
}
xlog.Info("Node registry initialized")
+ // Replica membership. NewNodeRegistry has just migrated the tables this
+ // reads, so it has to come after it.
+ clusterRegistry := cluster.NewRegistry(authDB)
+ 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.
+ xlog.Error("This replica is not registered in the cluster: no advertised address. Peers cannot reach it, and any worker whose tunnel lands here will be unroutable from every other replica",
+ "error", err, "knob", "LOCALAI_DISTRIBUTED_ADVERTISE_ADDR")
+ } else {
+ membership = cluster.NewMembership(clusterRegistry, cfg.Distributed.InstanceID, advertised, internal.PrintableVersion())
+ // 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.
+ peers := cluster.NewPeerPool(cfg.Distributed.InstanceID, cfg.Distributed.RegistrationToken, 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
@@ -197,11 +340,19 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
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.
healthMon := nodes.NewHealthMonitor(registry, authDB,
cfg.Distributed.HealthCheckIntervalOrDefault(),
cfg.Distributed.StaleNodeThresholdOrDefault(),
routerAuthToken,
!cfg.Distributed.DisablePerModelHealthCheck,
+ clusterRegistry,
+ cfg.Distributed.ReconnectGraceOrDefault(),
+ backendClients,
)
// Initialize job store
@@ -246,28 +397,42 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
}
xlog.Info("File manager initialized", "cacheDir", cacheDir)
+ // 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)
+
// Create FileStager for distributed file transfer
var fileStager nodes.FileStager
if cfg.Distributed.StorageURL != "" {
- fileStager = nodes.NewS3NATSFileStager(fileMgr, natsClient)
- xlog.Info("File stager initialized (S3+NATS)")
+ 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
}
- if node.HTTPAddress == "" {
- return "", fmt.Errorf("node %s has no HTTP address for file transfer", nodeID)
- }
- return node.HTTPAddress, nil
- }, cfg.Distributed.RegistrationToken)
+ // 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,
natsClient,
+ controlClient,
cfg.Distributed.BackendInstallTimeoutOrDefault(),
cfg.Distributed.BackendUpgradeTimeoutOrDefault(),
)
@@ -357,12 +522,19 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
conflictResolver = configLoader
}
modelCleanup := nodes.NewModelCleanupService(registry, remoteUnloader)
- router := nodes.NewSmartRouter(registry, nodes.SmartRouterOptions{
+ // 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,
@@ -393,7 +565,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
// 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
@@ -421,6 +593,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
Unloader: remoteUnloader,
Adapter: remoteUnloader,
RegistrationToken: cfg.Distributed.RegistrationToken,
+ ClientFactory: backendClients,
DB: authDB,
Interval: 30 * time.Second,
ScaleDownDelay: 5 * time.Minute,
@@ -429,30 +602,138 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
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
return &DistributedServices{
- Nats: natsClient,
- 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,
+ Nats: natsClient,
+ 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,
}, 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://")
}
diff --git a/core/application/startup.go b/core/application/startup.go
index abc2f4a17571..c790a1403617 100644
--- a/core/application/startup.go
+++ b/core/application/startup.go
@@ -283,9 +283,15 @@ func New(opts ...config.AppOption) (*Application, error) {
// Wire ModelRouter so grpcModel() delegates to SmartRouter in distributed mode
application.modelLoader.SetModelRouter(distSvc.ModelAdapter.AsModelRouter())
// Wire DistributedModelStore so shutdown/list/watchdog can find remote models
+ // The client factory is not optional here. Without it the store builds
+ // remote models with no client, and pkg/model.Model.GRPC then dials the
+ // worker's raw address with gRPC's own dialer, which is the direct dial
+ // the tunnel replaces; ShutdownModel's Free and the backend monitor's
+ // Status both reach it.
distStore := nodes.NewDistributedModelStore(
model.NewInMemoryModelStore(),
distSvc.Registry,
+ distSvc.BackendClients,
)
application.modelLoader.SetModelStore(distStore)
// Drop the local stub when a model's last replica leaves the registry.
diff --git a/core/application/unadvertised_replica_test.go b/core/application/unadvertised_replica_test.go
new file mode 100644
index 000000000000..ed9eeb4d6399
--- /dev/null
+++ b/core/application/unadvertised_replica_test.go
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: MIT
+
+package application
+
+import (
+ "context"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// The runtime symptom for a deferral whose cost changed between phases.
+//
+// Not refusing to start without an advertised address stays deferred on
+// purpose: refusing would take out every single-host deployment. What is not
+// deferred is telling the operator, repeatedly, that this replica is invisible
+// and which workers that is costing - because the symptom it produces (a worker
+// that 5xxs from most of the fleet) reads as a worker problem, and a single
+// startup line has scrolled away long before anyone goes looking.
+var _ = Describe("the alarm for a replica with no advertised address", func() {
+ It("keeps firing for as long as the state lasts, and names the workers it costs", func() {
+ // Repetition is the property. A one-shot alarm is the startup line
+ // again, which is what was already there and was not enough.
+ ctx, cancel := context.WithCancel(context.Background())
+ DeferCleanup(cancel)
+
+ alarms := make(chan []string, 8)
+ go nagUnadvertisedReplica(ctx, func() []string { return []string{"w1", "w2"} },
+ time.Millisecond, func(held []string) { alarms <- held })
+
+ // Two, not one: the second is what a one-shot implementation fails.
+ var first, second []string
+ Eventually(alarms, "10s").Should(Receive(&first))
+ Eventually(alarms, "10s").Should(Receive(&second))
+ Expect(first).To(ConsistOf("w1", "w2"),
+ "the workers this is costing are the answer to the question the symptom provokes")
+ Expect(second).To(ConsistOf("w1", "w2"))
+ })
+
+ It("reads the held set on every tick rather than the one it started with", func() {
+ // A replica accumulates tunnels while it runs, so an alarm bound to the
+ // set at startup would name an empty list forever on exactly the
+ // deployment where the cost is real.
+ ctx, cancel := context.WithCancel(context.Background())
+ DeferCleanup(cancel)
+
+ workers := make(chan []string, 32)
+ for range 32 {
+ workers <- []string{"w-late"}
+ }
+ alarms := make(chan []string, 8)
+ go nagUnadvertisedReplica(ctx, func() []string { return <-workers },
+ time.Millisecond, func(held []string) { alarms <- held })
+
+ var got []string
+ Eventually(alarms, "10s").Should(Receive(&got))
+ Expect(got).To(ConsistOf("w-late"))
+ })
+
+ It("stops when the process context ends", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ stopped := make(chan struct{})
+ go func() {
+ defer GinkgoRecover()
+ nagUnadvertisedReplica(ctx, func() []string { return nil }, time.Hour, func([]string) {})
+ close(stopped)
+ }()
+
+ cancel()
+ Eventually(stopped, "10s").Should(BeClosed())
+ })
+})
diff --git a/core/cli/run.go b/core/cli/run.go
index 6b9b3e4dc0b9..7af51b84612b 100644
--- a/core/cli/run.go
+++ b/core/cli/run.go
@@ -165,6 +165,7 @@ type RunCMD struct {
Distributed bool `env:"LOCALAI_DISTRIBUTED" default:"false" help:"Enable distributed mode (requires PostgreSQL + NATS)" group:"distributed"`
InstanceID string `env:"LOCALAI_INSTANCE_ID" help:"Unique instance ID for distributed mode (auto-generated UUID if empty)" group:"distributed"`
NatsURL string `env:"LOCALAI_NATS_URL" help:"NATS server URL (e.g., nats://localhost:4222)" group:"distributed"`
+ DistributedAdvertiseAddr string `env:"LOCALAI_DISTRIBUTED_ADVERTISE_ADDR" help:"host:port other frontend replicas dial to reach this one (peer link). Empty = derived from the local address that routes to PostgreSQL, which only works when the database is on another host." group:"distributed"`
StorageURL string `env:"LOCALAI_STORAGE_URL" help:"S3-compatible storage endpoint URL (e.g., http://minio:9000)" group:"distributed"`
StorageBucket string `env:"LOCALAI_STORAGE_BUCKET" default:"localai" help:"S3 bucket name for object storage" group:"distributed"`
StorageRegion string `env:"LOCALAI_STORAGE_REGION" default:"us-east-1" help:"S3 region" group:"distributed"`
@@ -182,6 +183,7 @@ type RunCMD struct {
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
ModelLoadTimeout string `env:"LOCALAI_NATS_MODEL_LOAD_TIMEOUT" help:"Fixed gRPC deadline for the remote LoadModel call sent to a worker node once its backend is installed and model files are staged. Unset (the default), the deadline is derived from the checkpoint size instead: 5m plus 20s per GiB, capped at 6h, so multi-tens-of-GB diffusion/video checkpoints get the minutes they need without a fixed cliff. Set this only to pin a specific budget; the value is used verbatim, including when it is shorter than the derived one." group:"distributed"`
ModelLoadWait string `env:"LOCALAI_MODEL_LOAD_WAIT" help:"How long an inference request waits for a model that is still cold-loading onto a worker before it is answered with 503, a Retry-After header and live staging progress (default 60s). The request is served the moment the model becomes ready, so a model already most of the way staged needs no client retry. Set to 0 to wait as long as the load takes — only safe when no ingress or load balancer with an idle timeout sits in front." group:"distributed"`
+ WorkerReconnectGrace string `env:"LOCALAI_WORKER_RECONNECT_GRACE" help:"How long a worker whose tunnel was lost is treated as reconnecting rather than gone (default 90s, clear of two of the worker's own ceiling backoffs plus the dial between them). Only after this window may the scheduler stop placing work on that worker and clean up its rows, so a value below the worker's backoff condemns workers that are re-homing normally; raise it to make a rolling frontend restart safer, lower it to reap a genuinely dead worker sooner. Measured on the database clock, so every replica agrees." group:"distributed"`
NatsAccountSeed string `env:"LOCALAI_NATS_ACCOUNT_SEED" help:"NATS account signing seed (SU...) used to mint per-node worker JWTs at registration" group:"distributed"`
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"NATS user JWT for the frontend (and agent workers) to publish control-plane messages" group:"distributed"`
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"NATS user signing seed (SU...) paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
@@ -351,6 +353,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
if r.InstanceID != "" {
opts = append(opts, config.WithDistributedInstanceID(r.InstanceID))
}
+ if r.DistributedAdvertiseAddr != "" {
+ opts = append(opts, config.WithDistributedAdvertiseAddr(r.DistributedAdvertiseAddr))
+ }
if r.NatsURL != "" {
opts = append(opts, config.WithNatsURL(r.NatsURL))
}
@@ -397,6 +402,13 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
}
opts = append(opts, config.WithModelLoadWait(d))
}
+ if r.WorkerReconnectGrace != "" {
+ d, err := parseDistributedDuration("LOCALAI_WORKER_RECONNECT_GRACE", r.WorkerReconnectGrace)
+ if err != nil {
+ return err
+ }
+ opts = append(opts, config.WithWorkerReconnectGrace(d))
+ }
if r.RegistrationToken != "" {
opts = append(opts, config.WithRegistrationToken(r.RegistrationToken))
}
diff --git a/core/cli/workerregistry/client.go b/core/cli/workerregistry/client.go
index cf46455c95c0..fb00fb3f1d86 100644
--- a/core/cli/workerregistry/client.go
+++ b/core/cli/workerregistry/client.go
@@ -8,7 +8,9 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
+ "io"
"net/http"
"strings"
"sync"
@@ -58,9 +60,15 @@ func (c *RegistrationClient) setAuth(req *http.Request) {
// RegisterResponse is the JSON body returned by /api/node/register.
type RegisterResponse struct {
- ID string `json:"id"`
- Status string `json:"status,omitempty"` // "pending" until an admin approves the node
- APIToken string `json:"api_token,omitempty"`
+ ID string `json:"id"`
+ Status string `json:"status,omitempty"` // "pending" until an admin approves the node
+ APIToken string `json:"api_token,omitempty"`
+ // TunnelToken is this node's own credential for GET /api/cluster/connect.
+ // The frontend mints a fresh one on every registration and keeps only its
+ // hash, so this is the ONLY time the plaintext exists anywhere but in this
+ // worker's memory: a worker that discards it cannot get it back without
+ // registering again.
+ TunnelToken string `json:"tunnel_token,omitempty"`
NatsJWT string `json:"nats_jwt,omitempty"`
NatsUserSeed string `json:"nats_user_seed,omitempty"`
}
@@ -87,7 +95,7 @@ func (c *RegistrationClient) RegisterFull(ctx context.Context, body map[string]a
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("registration failed with status %d", resp.StatusCode)
+ return nil, registrationStatusError(resp)
}
var result RegisterResponse
@@ -97,6 +105,58 @@ func (c *RegistrationClient) RegisterFull(ctx context.Context, body map[string]a
return &result, nil
}
+// ErrRegistrationRejected marks a registration the frontend REFUSED, as opposed
+// to one it could not answer.
+//
+// Retrying a refusal cannot change it: the request is wrong, or this worker is
+// not allowed to make it. The one that matters in practice is a worker of this
+// release registering against a frontend that predates it, which answers
+// "address is required for backend workers" with 400, because a worker no
+// longer has an address to send. Without this the retry ladder spends four
+// minutes on a verdict the frontend reached instantly, and the operator watches
+// it before being told anything.
+//
+// 408 and 429 are deliberately NOT rejections. Both are the frontend asking for
+// the same request again later, which is exactly what a retry does.
+var ErrRegistrationRejected = errors.New("the frontend refused this registration")
+
+// maxRegistrationErrorBody bounds how much of a refusal's body is quoted back.
+// Enough for a message, not enough for an HTML error page to bury the log line
+// it is meant to explain.
+const maxRegistrationErrorBody = 512
+
+// registrationStatusError turns a non-2xx response into an error that says WHY.
+//
+// The body is the point. The frontend explains its refusals there
+// ("address is required for backend workers", "invalid registration token"),
+// and discarding it left an operator with a bare status code: the one line that
+// would tell them which of several possible mistakes they made was read off the
+// socket and thrown away.
+func registrationStatusError(resp *http.Response) error {
+ detail, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationErrorBody))
+ if err != nil {
+ xlog.Debug("Could not read the frontend's registration error body", "status", resp.StatusCode, "error", err)
+ }
+ msg := strings.Join(strings.Fields(string(detail)), " ")
+ base := fmt.Sprintf("registration failed with status %d", resp.StatusCode)
+ if msg != "" {
+ base = fmt.Sprintf("%s: %s", base, msg)
+ }
+ if isRegistrationRejection(resp.StatusCode) {
+ return fmt.Errorf("%s: %w", base, ErrRegistrationRejected)
+ }
+ return errors.New(base)
+}
+
+// isRegistrationRejection reports whether a status is a verdict rather than a
+// condition that may pass.
+func isRegistrationRejection(status int) bool {
+ if status == http.StatusRequestTimeout || status == http.StatusTooManyRequests {
+ return false
+ }
+ return status >= 400 && status < 500
+}
+
// Register sends a single registration request and returns the node ID and
// optional credentials (API token for agent workers, NATS JWT when configured).
func (c *RegistrationClient) Register(ctx context.Context, body map[string]any) (nodeID, apiToken, natsJWT, natsSeed string, err error) {
@@ -108,27 +168,48 @@ func (c *RegistrationClient) Register(ctx context.Context, body map[string]any)
}
// RegisterWithRetry retries registration with exponential backoff.
+//
+// It drops every field of the response it does not name, the tunnel credential
+// among them. Callers that need one use RegisterFullWithRetry.
func (c *RegistrationClient) RegisterWithRetry(ctx context.Context, body map[string]any, maxRetries int) (nodeID, apiToken, natsJWT, natsSeed string, err error) {
+ res, err := c.RegisterFullWithRetry(ctx, body, maxRetries)
+ if err != nil {
+ return "", "", "", "", err
+ }
+ return res.ID, res.APIToken, res.NatsJWT, res.NatsUserSeed, nil
+}
+
+// RegisterFullWithRetry retries registration with exponential backoff and
+// returns the whole response.
+func (c *RegistrationClient) RegisterFullWithRetry(ctx context.Context, body map[string]any, maxRetries int) (*RegisterResponse, error) {
backoff := 2 * time.Second
maxBackoff := 30 * time.Second
+ var err error
for attempt := 1; attempt <= maxRetries; attempt++ {
- nodeID, apiToken, natsJWT, natsSeed, err = c.Register(ctx, body)
+ var res *RegisterResponse
+ res, err = c.RegisterFull(ctx, body)
if err == nil {
- return nodeID, apiToken, natsJWT, natsSeed, nil
+ return res, nil
+ }
+ if errors.Is(err, ErrRegistrationRejected) {
+ // A verdict, not an outage. Reported on the first attempt so the
+ // reason the frontend gave is the first thing in the log rather
+ // than the last, after the ladder.
+ return nil, err
}
if attempt == maxRetries {
- return "", "", "", "", fmt.Errorf("failed after %d attempts: %w", maxRetries, err)
+ return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries, err)
}
xlog.Warn("Registration failed, retrying", "attempt", attempt, "next_retry", backoff, "error", err)
select {
case <-ctx.Done():
- return "", "", "", "", ctx.Err()
+ return nil, ctx.Err()
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
}
- return nodeID, apiToken, natsJWT, natsSeed, err
+ return nil, err
}
// Heartbeat sends a single heartbeat POST with the given body.
diff --git a/core/cli/workerregistry/client_test.go b/core/cli/workerregistry/client_test.go
new file mode 100644
index 000000000000..5870d2524b5c
--- /dev/null
+++ b/core/cli/workerregistry/client_test.go
@@ -0,0 +1,138 @@
+package workerregistry
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// The case these specs exist for: a worker of this release registering against
+// a frontend that predates it. A worker no longer sends an address, the old
+// frontend requires one, and it answers 400 with the reason in the body. Two
+// things used to go wrong there at once. The reason was discarded, so the
+// operator saw only "status 400" and had to guess which of several mistakes
+// they had made; and the retry ladder spent four minutes on a verdict the
+// frontend reached instantly.
+var _ = Describe("Registration client refusals", func() {
+ var (
+ attempts atomic.Int32
+ status atomic.Int32
+ body atomic.Value // string
+ server *httptest.Server
+ client *RegistrationClient
+ // seen carries one token per request the handler served, so a spec can
+ // wait for the Nth attempt instead of sleeping for however long the
+ // ladder's backoff happens to be.
+ seen chan struct{}
+ )
+
+ BeforeEach(func() {
+ attempts.Store(0)
+ status.Store(int32(http.StatusBadRequest))
+ body.Store(`{"error":{"code":400,"message":"address is required for backend workers"}}`)
+ seen = make(chan struct{}, 64)
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ attempts.Add(1)
+ select {
+ case seen <- struct{}{}:
+ default:
+ }
+ w.WriteHeader(int(status.Load()))
+ _, _ = w.Write([]byte(body.Load().(string)))
+ }))
+ client = &RegistrationClient{FrontendURL: server.URL, HTTPTimeout: 2 * time.Second}
+ })
+
+ AfterEach(func() { server.Close() })
+
+ It("quotes what the frontend said", func() {
+ _, err := client.RegisterFull(context.Background(), map[string]any{"name": "w1"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("status 400"))
+ Expect(err.Error()).To(ContainSubstring("address is required for backend workers"))
+ })
+
+ It("marks a 4xx as a refusal", func() {
+ _, err := client.RegisterFull(context.Background(), map[string]any{"name": "w1"})
+ Expect(err).To(MatchError(ErrRegistrationRejected))
+ })
+
+ It("does not mark a 5xx as a refusal", func() {
+ // A frontend that is restarting or wedged has not judged anything, and
+ // retrying it is the whole reason the ladder exists.
+ status.Store(int32(http.StatusBadGateway))
+ body.Store("bad gateway")
+ _, err := client.RegisterFull(context.Background(), map[string]any{"name": "w1"})
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, ErrRegistrationRejected)).To(BeFalse())
+ })
+
+ DescribeTable("treats a status that asks for the same request again as retryable",
+ func(code int) {
+ status.Store(int32(code))
+ body.Store("later")
+ _, err := client.RegisterFull(context.Background(), map[string]any{"name": "w1"})
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, ErrRegistrationRejected)).To(BeFalse())
+ },
+ Entry("408 Request Timeout", http.StatusRequestTimeout),
+ Entry("429 Too Many Requests", http.StatusTooManyRequests),
+ )
+
+ It("stops the retry ladder on the first refusal", func() {
+ // Ten attempts on a 400 is roughly four minutes of backoff before the
+ // operator is told anything, and the answer is the same one the
+ // frontend gave immediately.
+ _, err := client.RegisterFullWithRetry(context.Background(), map[string]any{"name": "w1"}, 10)
+ Expect(err).To(MatchError(ErrRegistrationRejected))
+ Expect(err.Error()).To(ContainSubstring("address is required for backend workers"))
+ Expect(attempts.Load()).To(Equal(int32(1)))
+ })
+
+ It("still retries something that is not a refusal", func() {
+ // The control. Without it, a change that returned on EVERY error would
+ // pass the spec above and silently delete the retry behaviour a worker
+ // booting alongside its frontend depends on.
+ status.Store(int32(http.StatusServiceUnavailable))
+ body.Store("starting up")
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := client.RegisterFullWithRetry(ctx, map[string]any{"name": "w1"}, 10)
+ done <- err
+ }()
+
+ // Two tokens is the whole assertion: the ladder came back for a second
+ // attempt on a status that is not a verdict. Waiting on the handler
+ // rather than on a duration makes it exact instead of tolerant.
+ Eventually(seen).Should(Receive())
+ Eventually(seen, "10s").Should(Receive())
+ cancel()
+
+ var ladderErr error
+ Eventually(done).Should(Receive(&ladderErr))
+ Expect(ladderErr).To(HaveOccurred())
+ Expect(errors.Is(ladderErr, ErrRegistrationRejected)).To(BeFalse())
+ Expect(attempts.Load()).To(BeNumerically(">=", 2))
+ })
+
+ It("stops the credential manager's acquire loop on a refusal", func() {
+ // The default worker path goes through Acquire, not the ladder above,
+ // and its bound is 100 attempts rather than 10. A refusal there is the
+ // same verdict and has to end the same way.
+ mgr := NewNATSCredentialManager(func(ctx context.Context) (*RegisterResponse, error) {
+ return client.RegisterFull(ctx, map[string]any{"name": "w1"})
+ }, true)
+ _, err := mgr.Acquire(context.Background())
+ Expect(err).To(MatchError(ErrRegistrationRejected))
+ Expect(attempts.Load()).To(Equal(int32(1)))
+ })
+})
diff --git a/core/cli/workerregistry/credentials.go b/core/cli/workerregistry/credentials.go
index 24dd6f3c8ed7..b023b9916918 100644
--- a/core/cli/workerregistry/credentials.go
+++ b/core/cli/workerregistry/credentials.go
@@ -2,6 +2,7 @@ package workerregistry
import (
"context"
+ "errors"
"fmt"
"sync"
"time"
@@ -50,6 +51,11 @@ type NATSCredentialManager struct {
jwt string
seed string
nodeID string
+ // tunnelToken is the node's own tunnel credential from the most recent
+ // registration. It is kept here because every re-registration this manager
+ // performs ROTATES it, so the tunnel client has to read the current value
+ // at dial time rather than be handed one at startup.
+ tunnelToken string
}
// NewNATSCredentialManager builds a manager over register. When requireCreds is
@@ -87,6 +93,22 @@ func (m *NATSCredentialManager) store(res *RegisterResponse) {
if res.NatsJWT != "" && res.NatsUserSeed != "" {
m.jwt, m.seed = res.NatsJWT, res.NatsUserSeed
}
+ // Guarded the same way the NATS pair is: a response that carries no tunnel
+ // token (a frontend that predates them, or one whose minting failed) must
+ // not wipe a working credential this worker already holds. Overwriting with
+ // "" would lock the tunnel out until the next registration that did carry
+ // one, which is the opposite of what an empty field means.
+ if res.TunnelToken != "" {
+ m.tunnelToken = res.TunnelToken
+ }
+}
+
+// TunnelToken returns the node's current tunnel credential, empty until one has
+// been issued. It is the callback the tunnel client reads on every dial.
+func (m *NATSCredentialManager) TunnelToken() string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.tunnelToken
}
// Current returns the latest NATS credentials (both empty until acquired).
@@ -125,6 +147,11 @@ func (m *NATSCredentialManager) Acquire(ctx context.Context) (*RegisterResponse,
for attempt := 1; m.maxAttempts <= 0 || attempt <= m.maxAttempts; attempt++ {
res, err := m.register(ctx)
switch {
+ case errors.Is(err, ErrRegistrationRejected):
+ // The frontend refused rather than failed. Waiting through the full
+ // attempt ladder would delay the operator's only explanation by the
+ // length of the ladder and change nothing about the answer.
+ return nil, err
case err != nil:
lastReason = err
xlog.Warn("Registration failed, retrying", "attempt", attempt, "next_retry", backoff, "error", err)
diff --git a/core/config/distributed_config.go b/core/config/distributed_config.go
index 5a48a84e9b44..af2b375bf8a6 100644
--- a/core/config/distributed_config.go
+++ b/core/config/distributed_config.go
@@ -13,8 +13,15 @@ import (
// DistributedConfig holds configuration for horizontal scaling mode.
// When Enabled is true, PostgreSQL and NATS are required.
type DistributedConfig struct {
- Enabled bool // --distributed / LOCALAI_DISTRIBUTED
- InstanceID string // --instance-id / LOCALAI_INSTANCE_ID (auto-generated UUID if empty)
+ Enabled bool // --distributed / LOCALAI_DISTRIBUTED
+ InstanceID string // --instance-id / LOCALAI_INSTANCE_ID (auto-generated UUID if empty)
+ // AdvertiseAddr is the host:port OTHER REPLICAS dial to reach this one,
+ // which is not the address this process binds: a replica behind a service
+ // or a NAT binds one and is reached at another. Empty means "work it out",
+ // by asking the kernel which local address routes to PostgreSQL; that
+ // answer is only usable when the database is remote, so a deployment with
+ // a local or sidecar database has to set this.
+ AdvertiseAddr string // LOCALAI_DISTRIBUTED_ADVERTISE_ADDR
NatsURL string // --nats-url / LOCALAI_NATS_URL
StorageURL string // --storage-url / LOCALAI_STORAGE_URL (S3 endpoint)
RegistrationToken string // --registration-token / LOCALAI_REGISTRATION_TOKEN (required token for node registration)
@@ -74,6 +81,17 @@ type DistributedConfig struct {
MCPCIJobTimeout time.Duration // MCP CI job execution timeout (default 10m)
+ // WorkerReconnectGrace is how long a worker whose tunnel was lost is
+ // treated as reconnecting rather than gone. It is the ONLY thing that
+ // separates a worker re-homing between frontend replicas from one that has
+ // left, and absence is what makes the scheduler stop placing work and reap
+ // the worker's rows, so a grace shorter than the worker's own reconnect
+ // backoff condemns workers that are behaving exactly as designed.
+ //
+ // Zero means unset (DefaultWorkerReconnectGrace applies). Measured on the
+ // database clock, so every replica agrees on when the window ends.
+ WorkerReconnectGrace time.Duration // LOCALAI_WORKER_RECONNECT_GRACE
+
BackendInstallTimeout time.Duration // NATS round-trip timeout for backend.install (default 15m)
BackendUpgradeTimeout time.Duration // NATS round-trip timeout for backend.upgrade (default 15m)
// ModelLoadTimeout is the gRPC deadline for the remote LoadModel call the
@@ -175,6 +193,7 @@ func (c DistributedConfig) Validate() error {
FlagBackendInstallTimeout: c.BackendInstallTimeout,
FlagBackendUpgradeTimeout: c.BackendUpgradeTimeout,
FlagModelLoadTimeout: c.ModelLoadTimeout,
+ FlagWorkerReconnectGrace: c.WorkerReconnectGrace,
} {
if d < 0 {
return fmt.Errorf("%s must not be negative", name)
@@ -195,6 +214,14 @@ func WithDistributedInstanceID(id string) AppOption {
}
}
+// WithDistributedAdvertiseAddr pins the host:port peers dial to reach this
+// replica, overriding the route-based discovery.
+func WithDistributedAdvertiseAddr(addr string) AppOption {
+ return func(o *ApplicationConfig) {
+ o.Distributed.AdvertiseAddr = addr
+ }
+}
+
func WithNatsURL(url string) AppOption {
return func(o *ApplicationConfig) {
o.Distributed.NatsURL = url
@@ -307,6 +334,14 @@ func WithStorageSecretKey(key string) AppOption {
}
}
+// WithWorkerReconnectGrace sets how long a lost worker tunnel is read as
+// reconnecting rather than gone (see DistributedConfig.WorkerReconnectGrace).
+func WithWorkerReconnectGrace(d time.Duration) AppOption {
+ return func(o *ApplicationConfig) {
+ o.Distributed.WorkerReconnectGrace = d
+ }
+}
+
func WithBackendInstallTimeout(d time.Duration) AppOption {
return func(o *ApplicationConfig) {
o.Distributed.BackendInstallTimeout = d
@@ -401,7 +436,10 @@ const (
FlagBackendInstallTimeout = "backend-install-timeout"
FlagBackendUpgradeTimeout = "backend-upgrade-timeout"
FlagModelLoadTimeout = "model-load-timeout"
- FlagModelLoadWait = "model-load-wait"
+ // FlagWorkerReconnectGrace names the reconnect-grace knob. Validate quotes
+ // it when the operator hands it a negative duration.
+ FlagWorkerReconnectGrace = "worker-reconnect-grace"
+ FlagModelLoadWait = "model-load-wait"
// FlagDiskHeadroomCheck names the disk-headroom toggle. It is quoted in
// the warning the check emits while disabled, so the operator reading a
// log line knows exactly which knob produced it.
@@ -426,6 +464,30 @@ const (
// LocalAI (with progress the client can act on) rather than from a proxy
// dropping the connection.
DefaultModelLoadWait = 60 * time.Second
+ // DefaultWorkerReconnectGrace covers a worker that misses one reconnect at
+ // the ceiling and lands on the next, with margin. The worker's own numbers
+ // (core/services/worker/tunnel.go) are a 30s backoff ceiling
+ // (tunnelBackoffMax) and a 10s dial budget (tunnelHandshakeTimeout), so two
+ // ceiling waits with a hung dial between them puts the worker back at 70s,
+ // not 60s: two waits alone is the boundary, not a bound.
+ //
+ // The ceiling is reachable precisely when it matters. The backoff resets
+ // only after a session that lasted tunnelHealthyAfter (30s), which a
+ // replica accepting a dial and then dying denies, so a worker crossing a
+ // rolling frontend restart climbs to the ceiling rather than sitting near
+ // the 500ms floor.
+ //
+ // 90s therefore has margin where 60s sat on the edge. The asymmetry is
+ // deliberate: too short and a worker that is reconnecting exactly as
+ // designed is reported GONE, which licenses a reap and costs a model
+ // reload; too long and a worker that really has died is reaped later. The
+ // second is cheaper, so the default errs long.
+ //
+ // Raising it further makes a rolling frontend restart safer still; lowering
+ // it reaps a dead worker sooner. There IS a value at which a live worker is
+ // reported as gone: any grace shorter than that worker's actual reconnect.
+ // That is why this is a duration and not a boolean.
+ DefaultWorkerReconnectGrace = 90 * time.Second
)
// ModelLoadWaitUnbounded records LOCALAI_MODEL_LOAD_WAIT=0 — "wait as long as
@@ -474,6 +536,23 @@ func (c DistributedConfig) NatsAuthConfig() natsauth.Config {
}
}
+// ReconnectGraceOrDefault returns the configured worker reconnect grace or the
+// default.
+//
+// A non-positive value falls back rather than being taken verbatim, which is
+// the opposite of what the timeout knobs above do, and deliberately. A
+// negative grace makes every departure older than the window the instant it is
+// stamped, so a worker two seconds into a normal reconnect reports as GONE, and
+// gone is the one answer a caller may reap and evict on. Validate rejects a
+// negative duration at startup; this is the second line, for a config built in
+// code that never went through it.
+func (c DistributedConfig) ReconnectGraceOrDefault() time.Duration {
+ if c.WorkerReconnectGrace <= 0 {
+ return DefaultWorkerReconnectGrace
+ }
+ return c.WorkerReconnectGrace
+}
+
// BackendInstallTimeoutOrDefault returns the configured timeout or the default.
func (c DistributedConfig) BackendInstallTimeoutOrDefault() time.Duration {
return cmp.Or(c.BackendInstallTimeout, DefaultBackendInstallTimeout)
diff --git a/core/config/distributed_config_test.go b/core/config/distributed_config_test.go
index ec7fbe8dc7e9..deab290fa461 100644
--- a/core/config/distributed_config_test.go
+++ b/core/config/distributed_config_test.go
@@ -176,3 +176,52 @@ var _ = Describe("DistributedConfig.Validate registration auth", func() {
Expect(err.Error()).To(ContainSubstring("LOCALAI_NATS_REQUIRE_AUTH"))
})
})
+
+var _ = Describe("DistributedConfig worker reconnect grace", func() {
+ It("defaults clear of two ceiling backoffs plus the dial between them", func() {
+ // The worker's own numbers (core/services/worker/tunnel.go): a 30s
+ // backoff ceiling and a 10s dial budget, so two ceiling waits with a
+ // hung dial between them puts the worker back at 70s. 60s would sit
+ // under that and condemn a worker reconnecting exactly as designed;
+ // 90s clears it with margin.
+ Expect(config.DistributedConfig{}.ReconnectGraceOrDefault()).To(Equal(90 * time.Second))
+ Expect(config.DefaultWorkerReconnectGrace).To(BeNumerically(">", 70*time.Second),
+ "the default must clear two ceiling backoffs plus one handshake timeout")
+ })
+
+ It("takes a configured worker reconnect grace verbatim", func() {
+ cfg := config.DistributedConfig{WorkerReconnectGrace: 5 * time.Minute}
+ Expect(cfg.ReconnectGraceOrDefault()).To(Equal(5 * time.Minute))
+ })
+
+ It("falls back to the default rather than condemning every worker on a negative value", func() {
+ // A negative grace makes every departure older than the window the
+ // instant it is stamped, which reports a worker that has been gone for
+ // two seconds as GONE, and gone is the one value a caller may reap on.
+ cfg := config.DistributedConfig{WorkerReconnectGrace: -1 * time.Second}
+ Expect(cfg.ReconnectGraceOrDefault()).To(Equal(config.DefaultWorkerReconnectGrace))
+ })
+
+ It("refuses to start on a negative grace rather than reaping on it", func() {
+ // The flag is in Validate's negative-duration table, and this is what
+ // says so. A negative grace makes every departure older than the window
+ // the instant it is stamped, so the deployment would answer GONE for
+ // every worker that has ever lost a tunnel, and gone is the one answer
+ // a caller may reap and evict on.
+ c := config.DistributedConfig{
+ Enabled: true,
+ NatsURL: "nats://localhost:4222",
+ RegistrationToken: "tok",
+ WorkerReconnectGrace: -1 * time.Second,
+ }
+ err := c.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring(config.FlagWorkerReconnectGrace))
+ })
+
+ It("is settable through the application option", func() {
+ o := &config.ApplicationConfig{}
+ config.WithWorkerReconnectGrace(90 * time.Second)(o)
+ Expect(o.Distributed.ReconnectGraceOrDefault()).To(Equal(90 * time.Second))
+ })
+})
diff --git a/core/http/app.go b/core/http/app.go
index 2e1453ac0e38..ec2b864ee8b7 100644
--- a/core/http/app.go
+++ b/core/http/app.go
@@ -1,12 +1,14 @@
package http
import (
+ "context"
"embed"
"errors"
"fmt"
"io/fs"
"math"
"mime"
+ "net"
"net/http"
"os"
"path/filepath"
@@ -28,6 +30,7 @@ import (
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/schema"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/LocalAI/core/services/distributed"
"github.com/mudler/LocalAI/core/services/finetune"
"github.com/mudler/LocalAI/core/services/galleryop"
@@ -566,15 +569,79 @@ func API(application *application.Application) (*echo.Echo, error) {
distCfg := application.ApplicationConfig().Distributed
var registry *nodes.NodeRegistry
var remoteUnloader nodes.NodeCommandSender
+ // How the admin log-proxy routes reach a worker's own HTTP server. Left nil
+ // outside distributed mode, where there are no workers and no tunnels; the
+ // routes then refuse rather than dialling an address directly.
+ var workerHTTPDialFor nodes.WorkerNetDialerFor
if d := application.Distributed(); d != nil {
registry = d.Registry
if d.Router != nil {
remoteUnloader = d.Router.Unloader()
}
+ if d.WorkerDialer != nil {
+ workerHTTPDialFor = func(nodeID string) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ return d.WorkerDialer.DialerFor(nodeID, clustersvc.StreamTagHTTP)
+ }
+ }
}
natsCfg := distCfg.NatsAuthConfig()
routes.RegisterNodeSelfServiceRoutes(e, registry, distCfg.RegistrationToken, distCfg.AutoApproveNodes, application.AuthDB(), application.ApplicationConfig().Auth.APIKeyHMACSecret, natsCfg)
- routes.RegisterNodeAdminRoutes(e, registry, remoteUnloader, application.GalleryService(), opcache, application.ApplicationConfig(), adminMiddleware, application.AuthDB(), application.ApplicationConfig().Auth.APIKeyHMACSecret, application.ApplicationConfig().Distributed.RegistrationToken, natsCfg)
+ routes.RegisterNodeAdminRoutes(e, registry, remoteUnloader, application.GalleryService(), opcache, application.ApplicationConfig(), adminMiddleware, application.AuthDB(), application.ApplicationConfig().Auth.APIKeyHMACSecret, application.ApplicationConfig().Distributed.RegistrationToken, natsCfg, workerHTTPDialFor)
+
+ // Replica-to-replica peer link. Registered only in distributed mode: in
+ // single-node mode there are no peers, and the route authenticates with the
+ // registration token, so publishing it unconditionally would put a
+ // multiplexer on every single-binary install.
+ if d := application.Distributed(); d != nil && d.PeerSessions != nil {
+ if distCfg.RegistrationToken == "" {
+ // The handler fails closed on an empty token, which is right and
+ // invisible: without this line an operator sees only 401s on a
+ // route they never configured, and nothing connecting them to the
+ // token they did not set.
+ xlog.Warn("Replica peer link will refuse every dial: no registration token is configured",
+ "route", clustersvc.PeerPath, "knob", "LOCALAI_REGISTRATION_TOKEN")
+ }
+ routes.RegisterClusterRoutes(e, distCfg.RegistrationToken, d.PeerSessions.Accept)
+ }
+
+ // The worker tunnel, registered unconditionally. Both arguments are nil
+ // outside distributed mode and the handler refuses every dial then, which
+ // is what makes registering it always safe; what it buys is the
+ // route-coverage test walking the route in a plain single-binary
+ // application, and that test is what holds the rule that an unauthenticated
+ // dial is refused BEFORE the WebSocket upgrade.
+ var tunnels *clustersvc.TunnelRegistry
+ if d := application.Distributed(); d != nil {
+ tunnels = d.Tunnels
+ if distCfg.RegistrationToken == "" {
+ // A different warning from the peer link's, for the same missing
+ // knob, because what breaks is different. Tunnels themselves work
+ // without a registration token: each node is minted its own tunnel
+ // credential at registration whether or not one is configured. What
+ // is missing is the gate in FRONT of that. With no registration
+ // token, RegisterNodeEndpoint validates nothing, so anyone who can
+ // reach this frontend can register a node and be issued a tunnel
+ // credential for it.
+ //
+ // How far that gets them depends on the OTHER knob. With
+ // auto-approve on, the node is healthy at once and the credential
+ // works immediately. With it off, the node is pending, and the
+ // tunnel route refuses a pending node on every dial, so the
+ // credential is inert until an admin approves it and approval is
+ // the real gate. Worth stating precisely, because the same commit
+ // argues exactly this distinction three files away to justify
+ // minting for pending nodes at all.
+ //
+ // This warning replaced one that said the opposite, that tunnels
+ // would refuse every dial without this token. That was true while
+ // the tunnel authenticated against the registration token's own
+ // hash, and stopped being true when nodes got credentials of their
+ // own.
+ xlog.Warn("Node registration is unauthenticated, so any caller that can reach this frontend can register a worker and be issued a tunnel credential",
+ "route", clustersvc.ConnectPath, "knob", "LOCALAI_REGISTRATION_TOKEN")
+ }
+ }
+ routes.RegisterWorkerTunnelRoute(e, registry, tunnels)
// Distributed SSE routes (job progress + agent events via NATS)
if d := application.Distributed(); d != nil {
diff --git a/core/http/auth/public_routes.go b/core/http/auth/public_routes.go
index 658205a78f8f..2c1e1dc3cdd1 100644
--- a/core/http/auth/public_routes.go
+++ b/core/http/auth/public_routes.go
@@ -74,8 +74,26 @@ func isPublicRoute(method, path string) bool {
return false
}
+// ClusterPathPrefix is the machine-to-machine cluster namespace. It carries two
+// different trust relationships, on two different credentials: the
+// replica-to-replica peer link, which checks the shared cluster token, and the
+// worker-to-frontend tunnel, which checks the dialing node's own stored token
+// hash. What they have in common is the only thing this prefix asserts, that
+// each handler checks its own Authorization header, so the check below lets them
+// through the global session middleware rather than rejecting a caller that has
+// no session and no user.
+//
+// The cluster routes do NOT derive their paths from this constant: they are
+// registered from core/services/cluster's own literal, because that package
+// must not import core/http/auth. Nothing in the compiler holds the two
+// together, so a spec does instead, driving a peer request through this
+// middleware in core/http/endpoints/cluster/peer_test.go. Moving either string
+// without the other turns that spec red, which is the whole reason it exists.
+const ClusterPathPrefix = "/api/cluster/"
+
// usesAlternativeAuthentication identifies requests whose credentials are
// validated by route-group middleware instead of the global auth middleware.
func usesAlternativeAuthentication(path string) bool {
- return strings.HasPrefix(path, "/api/node/")
+ return strings.HasPrefix(path, "/api/node/") ||
+ strings.HasPrefix(path, ClusterPathPrefix)
}
diff --git a/core/http/endpoints/cluster/cluster_suite_test.go b/core/http/endpoints/cluster/cluster_suite_test.go
new file mode 100644
index 000000000000..00f45e37a672
--- /dev/null
+++ b/core/http/endpoints/cluster/cluster_suite_test.go
@@ -0,0 +1,13 @@
+package cluster_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestClusterEndpoints(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Cluster Endpoints Suite")
+}
diff --git a/core/http/endpoints/cluster/connect.go b/core/http/endpoints/cluster/connect.go
new file mode 100644
index 000000000000..8d175704669c
--- /dev/null
+++ b/core/http/endpoints/cluster/connect.go
@@ -0,0 +1,262 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/xlog"
+ "gorm.io/gorm"
+)
+
+// ConnectHandler serves the door a worker knocks on: it authenticates the dial
+// against the node's OWN stored token, upgrades it to a WebSocket, wraps that as
+// a yamux server session and attaches it to the tunnel registry.
+//
+// The worker dials out and never listens, which is the whole point of the
+// tunnel: a worker behind NAT, in another cluster, or on a laptop needs no
+// inbound port. It is therefore the yamux CLIENT and this side the SERVER, so
+// this side owns the even stream IDs and is the side that opens streams.
+// Nothing here accepts streams: in this design the frontend asks and the worker
+// answers, so a worker that opened a stream into this session would park on the
+// accept backlog rather than be served.
+//
+// The route is registered in every deployment, including single-binary ones, so
+// that the route-coverage test under build tag `auth` walks it. A nil registry
+// or a nil tunnel registry therefore has to be a real answer rather than a
+// panic; see the 503 below.
+//
+// It is deliberately absent from auth.RouteFeatureRegistry. That registry gates
+// a route on the FEATURES OF AN AUTHENTICATED USER, resolved from auth.GetUser,
+// and there is no user here: the dialer is a worker process holding a machine
+// credential.
+//
+// The global auth middleware does RUN on this path; what it does not do is
+// reject. It attempts session, bearer and legacy-key authentication first, so it
+// may even have set auth_user from a worker token that happens to match an API
+// key, and then core/http/auth/middleware.go:90 lets the request through
+// because usesAlternativeAuthentication reports the path as one whose
+// credentials its own route checks. Nothing here reads what it set.
+func ConnectHandler(registry *nodes.NodeRegistry, tunnels *clustersvc.TunnelRegistry) echo.HandlerFunc {
+ // gorilla's default CheckOrigin restricts a browser to same-origin and lets
+ // a header-less client (which every worker is) through, so the zero value
+ // is what this link wants. The same choice PeerHandler makes.
+ upgrader := websocket.Upgrader{}
+
+ return func(c echo.Context) error {
+ // Everything below happens BEFORE the upgrade, and the order inside it
+ // is load-bearing. The credential is read first because a dial with no
+ // Authorization header at all is the anonymous case, and the
+ // route-coverage test issues exactly that, with no query string: it
+ // must see 401 rather than a 400 about a missing node id.
+ token, ok := bearerToken(c.Request())
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
+ }
+
+ // Not 401. A frontend with no cluster cannot authenticate anybody, and
+ // answering "unauthorized" would send the operator hunting a token
+ // problem that does not exist. It is checked after the header so that
+ // an anonymous dial still gets the 401 the coverage test requires.
+ //
+ // Only the registry half is covered by a spec. The two are read from one
+ // application.Distributed() in core/http/app.go and initDistributed
+ // returns an error rather than a partial struct, so a non-nil registry
+ // beside a nil tunnel registry is unreachable and no spec constructs it;
+ // the second half is defence against a future wiring that splits them,
+ // where the cost would be a nil dereference in Attach after the
+ // connection is already hijacked.
+ if registry == nil || tunnels == nil {
+ return echo.NewHTTPError(http.StatusServiceUnavailable, "distributed mode not enabled")
+ }
+
+ nodeID := c.QueryParam("id")
+ if nodeID == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, "missing node id")
+ }
+
+ node, err := registry.Get(c.Request().Context(), nodeID)
+ switch {
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ // A node this frontend has never seen. Reported as 401 rather than
+ // 404 so a caller cannot enumerate node IDs by status code.
+ xlog.Debug("worker tunnel dial named an unknown node", "node", nodeID)
+ return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
+ case err != nil:
+ // A query that FAILED is neither a rejection nor an absence. This
+ // is the phase's standing rule in its HTTP form: telling a worker
+ // its credentials are wrong when the database merely could not be
+ // read sends it re-registering instead of retrying, and a worker
+ // that re-registers has thrown away the identity its tunnel and its
+ // loaded models are keyed by.
+ xlog.Error("Looking up a worker for its tunnel dial failed", "node", nodeID, "error", err)
+ return echo.NewHTTPError(http.StatusInternalServerError, "node lookup failed")
+ }
+
+ // Split from the mismatch below because they are different operator
+ // problems with different fixes. An empty stored hash means this node
+ // last registered against a LocalAI that predates per-node tunnel
+ // credentials, so it holds no secret this route can check and must
+ // register again; a mismatch means the worker is presenting the wrong
+ // one, usually a stale credential from before a rotation. One log line
+ // for both leaves an operator reading "wrong token" while a whole fleet
+ // of not-yet-restarted workers fails identically.
+ if node.TunnelTokenHash == "" {
+ // Debug, not Warn. Every worker in that state fails this way on
+ // every reconnect, so warning per dial buries the log.
+ xlog.Debug("refusing a worker tunnel: this node has no tunnel credential, so it has not registered since they were introduced",
+ "node", nodeID)
+ return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
+ }
+ if !authorizedWorker(token, node.TunnelTokenHash) {
+ xlog.Debug("worker tunnel dial presented the wrong token", "node", nodeID)
+ return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
+ }
+
+ // Authenticated but not authorised, so 403 rather than 401: the fix is an
+ // admin approving the node, not a different credential, and answering
+ // 401 would send an operator looking at tokens.
+ //
+ // Only StatusPending is refused. The rest of /api/node/ self-service
+ // gates on nothing at all, but the two places that hand a node something
+ // DURABLE both refuse a pending one: the agent worker's API key
+ // (provisionAgentWorkerKey, guarded at its call site in
+ // core/http/endpoints/localai/nodes.go) and its NATS credential
+ // (attachNatsJWT in the same file). Cited by NAME, not by line: the
+ // previous version of this comment cited line numbers into a file this
+ // same commit was editing, and both were stale before it landed.
+ //
+ // A tunnel is that kind of grant, not a heartbeat: it is
+ // a standing pipe into the worker recorded in node_connections and
+ // relayed to by every other replica. Draining and unhealthy nodes keep
+ // their tunnels on purpose; draining means finish what you have, and a
+ // node marked unhealthy for missed heartbeats needs the pipe to recover
+ // through.
+ if node.Status == nodes.StatusPending {
+ xlog.Warn("Refusing a worker tunnel: this node is awaiting admin approval", "node", nodeID)
+ return echo.NewHTTPError(http.StatusForbidden, "node is pending approval")
+ }
+
+ ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ // Upgrade has already written its own failure to the client.
+ xlog.Debug("worker tunnel upgrade failed", "node", nodeID, "error", err)
+ return nil
+ }
+
+ sess, err := yamux.Server(clustersvc.WebsocketConn(ws), nil, nil)
+ if err != nil {
+ xlog.Error("Worker tunnel session setup failed", "node", nodeID, "error", err)
+ _ = ws.Close()
+ return nil
+ }
+
+ // The same guard PeerHandler carries, for the same reason and one more.
+ // net/http recovers a panic from this goroutine but does not close the
+ // hijacked connection, and middleware.Recover does not either, so a
+ // panic below would leave the worker holding a live session this replica
+ // has no entry for and will never detach. The extra reason here is that
+ // Attach does database work: a panic inside it, with the session left
+ // open, is a tunnel nothing can reach and nothing will clean up.
+ //
+ // It re-panics rather than swallowing. Whatever it caught is a bug, and
+ // the recovery middleware above is what should report it.
+ defer func() {
+ if r := recover(); r != nil {
+ _ = sess.Close()
+ panic(r)
+ }
+ }()
+
+ // From here the connection is hijacked, so no status can reach the
+ // worker any more: a failure is a closed socket, which is what its
+ // reconnect loop reads.
+ epoch, err := tunnels.Attach(c.Request().Context(), nodeID, sess)
+ if err != nil {
+ xlog.Error("Attaching a worker tunnel failed", "node", nodeID, "error", err)
+ _ = sess.Close()
+ return nil
+ }
+
+ xlog.Info("Worker tunnel established", "node", nodeID, "remote", ws.RemoteAddr().String())
+ // The session outlives this handler, so something other than the
+ // request goroutine has to notice it die. yamux closes shutdownCh from
+ // its receive loop the moment the underlying conn fails
+ // (go-yamux/v5@v5.1.0/session.go:691-695 calling close at
+ // session.go:297-311), and its default config keepalives every 30s
+ // (mux.go:73-74), so a worker that vanishes without a FIN is noticed
+ // too rather than held forever.
+ go func() {
+ <-sess.CloseChan()
+ // The token Attach returned, never a fresh or zero one. Detach
+ // matches it by EQUALITY: it identifies THIS attachment, so a
+ // worker that has already re-dialled onto this replica is not
+ // evicted by its predecessor's teardown.
+ tunnels.Detach(nodeID, epoch)
+ xlog.Debug("worker tunnel closed", "node", nodeID)
+ }()
+ return nil
+ }
+}
+
+// bearerToken returns the token from an Authorization: Bearer header, and
+// whether one was present at all.
+//
+// The presence of a credential and its correctness are separate answers on
+// purpose: "no credential" is what decides the pre-upgrade 401, and it has to be
+// decidable before anything about the node is known.
+func bearerToken(r *http.Request) (string, bool) {
+ // RFC 7235 makes the scheme case-insensitive; the token after it is not.
+ const prefix = "Bearer "
+ header := r.Header.Get("Authorization")
+ if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
+ return "", false
+ }
+ token := header[len(prefix):]
+ if token == "" {
+ return "", false
+ }
+ return token, true
+}
+
+// authorizedWorker compares a presented token against the hash stored on the
+// node's own row, in constant time.
+//
+// Against the NODE's OWN tunnel credential, not the deployment's registration
+// token. A tunnel is a durable, multiplexed pipe into a worker, and a
+// credential that authorizes every worker at once would mean one leak lets an
+// attacker impersonate any worker whose ID it can read and take over that
+// worker's traffic by claiming its tunnel. The secret compared here is minted
+// per node at registration (attachTunnelToken in
+// core/http/endpoints/localai/nodes.go), returned to that worker once, and
+// stored only as this hash, so knowing the registration token no longer gets
+// anyone a tunnel.
+//
+// Note which column: BackendNode.TunnelTokenHash, not TokenHash. TokenHash is
+// still the hash of whatever token the worker registered WITH, which on most
+// deployments is the shared registration token, and comparing against it is
+// exactly the weakness this replaced.
+//
+// The empty-hash guard is defensive rather than deciding: a stored hash is
+// hex-encoded SHA-256, so 64 bytes or nothing, and ConstantTimeCompare already
+// returns 0 on a length mismatch (crypto/internal/fips140/subtle/constant_time.go:17-20
+// returns 0 outright when the lengths differ). It is kept because a reader should not have to
+// derive "a node with no credential authorizes nobody" from a length rule, and
+// because the caller logs that case separately.
+func authorizedWorker(token, storedHash string) bool {
+ if storedHash == "" {
+ return false
+ }
+ sum := sha256.Sum256([]byte(token))
+ return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(storedHash)) == 1
+}
diff --git a/core/http/endpoints/cluster/connect_test.go b/core/http/endpoints/cluster/connect_test.go
new file mode 100644
index 000000000000..15206d50444c
--- /dev/null
+++ b/core/http/endpoints/cluster/connect_test.go
@@ -0,0 +1,391 @@
+package cluster_test
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/http/auth"
+ "github.com/mudler/LocalAI/core/http/routes"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+// workerToken is the tunnel credential one worker holds. Registration mints it
+// per node and keeps only its hash, which is the whole point of the check the
+// specs below pin: a second worker's credential, and the deployment-wide
+// registration token, are both wrong for this node.
+const workerToken = "worker-1-secret"
+
+// registrationToken stands in for the shared secret every worker in a
+// deployment registers with. It is stored on the row too, in a DIFFERENT
+// column, and a spec below pins that presenting it does not open a tunnel.
+const registrationToken = "deployment-registration-token"
+
+func tokenHash(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// bearer builds the header a worker dials with.
+func bearer(token string) http.Header {
+ h := http.Header{}
+ h.Set("Authorization", "Bearer "+token)
+ return h
+}
+
+// wsConnectURL is the worker tunnel route on a test server, named as nodeID.
+func wsConnectURL(s *httptest.Server, nodeID string) string {
+ return "ws" + strings.TrimPrefix(s.URL, "http") + clustersvc.ConnectPath +
+ "?id=" + url.QueryEscape(nodeID)
+}
+
+var _ = Describe("Worker tunnel handler", func() {
+ var (
+ srv *httptest.Server
+ db *gorm.DB
+ reg *clustersvc.Registry
+ tun *clustersvc.TunnelRegistry
+ nodeID string
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+
+ nodeReg, err := nodes.NewNodeRegistry(db)
+ Expect(err).ToNot(HaveOccurred())
+
+ node := &nodes.BackendNode{
+ Name: "worker-1",
+ Address: "10.0.0.9:50051",
+ // Both hashes are set, and they differ. That is what a real
+ // registration produces: TokenHash is the shared token the worker
+ // registered WITH, TunnelTokenHash is the secret minted FOR it.
+ TokenHash: tokenHash(registrationToken),
+ TunnelTokenHash: tokenHash(workerToken),
+ }
+ Expect(nodeReg.Register(ctx, node, true)).To(Succeed())
+ nodeID = node.ID
+ Expect(nodeID).ToNot(BeEmpty())
+
+ reg = clustersvc.NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ tun = clustersvc.NewTunnelRegistry(reg, "me")
+
+ e := echo.New()
+ routes.RegisterWorkerTunnelRoute(e, nodeReg, tun)
+ srv = httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+ })
+
+ It("refuses an anonymous dial before upgrading", func() {
+ // A plain GET, not a WebSocket dial: this is exactly what the
+ // route-coverage test issues, and a handler that upgrades first answers
+ // it with gorilla's own 400 handshake failure instead of a 401.
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+clustersvc.ConnectPath+"?id="+nodeID, nil)
+ Expect(err).ToNot(HaveOccurred())
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(resp.Header.Get("Upgrade")).To(BeEmpty(),
+ "the handler upgraded an unauthenticated dial")
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("refuses a dial that carries no credentials at all", func() {
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), nil)
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("refuses the deployment's registration token, which this node also stores", func() {
+ // The registration token is the credential every worker in the
+ // deployment holds, and it IS on this node's row, in TokenHash.
+ // Accepting it would mean one leaked shared secret impersonates any
+ // worker whose ID an attacker can read; the node's own tunnel
+ // credential is the only thing this route accepts.
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(registrationToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("refuses a dial that names a node it has never seen", func() {
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, "no-such-node"), bearer(workerToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("refuses an authenticated dial that names no node", func() {
+ _, resp, err := websocket.DefaultDialer.Dial(
+ "ws"+strings.TrimPrefix(srv.URL, "http")+clustersvc.ConnectPath, bearer(workerToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ })
+
+ It("attaches an authenticated worker and carries bytes to it", func() {
+ conn, _, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ // The worker is the side that dials, so its half of the mux is the
+ // yamux CLIENT and the frontend's is the server.
+ workerSess, err := yamux.Client(clustersvc.WebsocketConn(conn), nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = workerSess.Close() })
+
+ Eventually(tun.Held, "10s").Should(ConsistOf(nodeID))
+
+ owner, _, err := reg.OwnerRow(ctx, nodeID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("me"),
+ "the tunnel was stored without the claim that tells other replicas where it is")
+
+ go func() {
+ defer GinkgoRecover()
+ stream, aerr := workerSess.AcceptStream()
+ if aerr != nil {
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ buf := make([]byte, 4)
+ if _, rerr := stream.Read(buf); rerr != nil {
+ return
+ }
+ _, _ = stream.Write(buf)
+ }()
+
+ stream, err := tun.Open(ctx, nodeID)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ _, err = stream.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ echoed := make([]byte, 4)
+ _, err = stream.Read(echoed)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(echoed)).To(Equal("ping"))
+ })
+
+ It("detaches the tunnel and drops its claim when the worker goes away", func() {
+ conn, _, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(tun.Held, "10s").Should(ConsistOf(nodeID))
+
+ Expect(conn.Close()).To(Succeed())
+
+ Eventually(tun.Held, "10s").Should(BeEmpty(),
+ "a dead tunnel is still held here, so every dialer routed to this replica gets a socket that carries nothing")
+ Eventually(func() error {
+ _, _, err := reg.OwnerRow(ctx, nodeID)
+ return err
+ }, "10s").Should(MatchError(clustersvc.ErrNoConnection),
+ "the claim outlived the socket, so this replica keeps being named the owner of a worker it no longer holds")
+ })
+
+ It("refuses a node with no tunnel credential, without falling back to its registration token", func() {
+ // A node registered by a LocalAI predating per-node tunnel credentials
+ // produces exactly this row: a registration-token hash in token_hash
+ // and nothing in tunnel_token_hash. It cannot be back-filled, because
+ // the plaintext only ever existed in the response that minted it, so
+ // such a node must register again.
+ //
+ // The dial presents the REGISTRATION token, which is still on the row.
+ // A handler that fell back to token_hash when the tunnel hash is empty
+ // would let it in, which is the weakness this whole change removed.
+ Expect(db.Exec(`UPDATE backend_nodes SET tunnel_token_hash = '' WHERE id = ?`, nodeID).Error).To(Succeed())
+
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(registrationToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("refuses a node that is still awaiting admin approval", func() {
+ // Approval is what gates a node's participation, and a tunnel is a
+ // standing pipe recorded in node_connections, not a heartbeat. 403 and
+ // not 401: the credential is right, the authorisation is missing, and
+ // the fix is an admin rather than a different token.
+ Expect(db.Exec(`UPDATE backend_nodes SET status = ? WHERE id = ?`, nodes.StatusPending, nodeID).Error).To(Succeed())
+
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusForbidden))
+ Expect(tun.Held()).To(BeEmpty())
+ })
+
+ It("still admits a draining node, which has work to finish", func() {
+ // Only pending is refused. Draining means "start nothing new", not
+ // "lose the pipe your in-flight requests travel on", and a node marked
+ // unhealthy for missed heartbeats needs the tunnel to recover through.
+ Expect(db.Exec(`UPDATE backend_nodes SET status = ? WHERE id = ?`, nodes.StatusDraining, nodeID).Error).To(Succeed())
+
+ conn, _, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Eventually(tun.Held, "10s").Should(ConsistOf(nodeID))
+ })
+
+ It("reports a lookup failure as a failure, not as a refusal", func() {
+ // ErrNotOwner, 401 and 404 are all ANSWERS. A database that cannot be
+ // read is none of them: telling a worker its credentials are wrong when
+ // the frontend simply could not look them up sends it re-registering
+ // instead of retrying.
+ Expect(db.Exec(`DROP TABLE backend_nodes CASCADE`).Error).To(Succeed())
+
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusInternalServerError))
+ })
+})
+
+var _ = Describe("Worker tunnel handler when the attach panics", func() {
+ // net/http recovers a panic from the request goroutine but does NOT close a
+ // hijacked connection, so without the handler's own recover the worker keeps
+ // a live session this replica has no entry for and will never detach: its
+ // opens fill yamux's 256-deep backlog and then hang with no error. The panic
+ // is injected through a real path rather than a fake one, a registry built
+ // over no database at all, which is what Attach's first database call
+ // dereferences.
+ var (
+ srv *httptest.Server
+ db *gorm.DB
+ nodeID string
+ )
+
+ BeforeEach(func() {
+ ctx := context.Background()
+ db = testutil.SetupTestDB()
+ nodeReg, err := nodes.NewNodeRegistry(db)
+ Expect(err).ToNot(HaveOccurred())
+ node := &nodes.BackendNode{Name: "worker-1", Address: "10.0.0.9:50051", TunnelTokenHash: tokenHash(workerToken)}
+ Expect(nodeReg.Register(ctx, node, true)).To(Succeed())
+ nodeID = node.ID
+
+ e := echo.New()
+ routes.RegisterWorkerTunnelRoute(e, nodeReg, clustersvc.NewTunnelRegistry(nil, "me"))
+ srv = httptest.NewServer(e)
+ DeferCleanup(func() {
+ // A hijacked connection the handler never closed would park Close
+ // forever, turning the assertion below into a suite hang.
+ srv.CloseClientConnections()
+ srv.Close()
+ })
+ })
+
+ It("closes the worker's session instead of stranding it", func() {
+ conn, _, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, nodeID), bearer(workerToken))
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ workerSess, err := yamux.Client(clustersvc.WebsocketConn(conn), nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = workerSess.Close() })
+
+ // Asserting on OpenStream would hang rather than fail: yamux only
+ // acknowledges a stream once the peer accepts it, and the leak this
+ // pins is precisely that nobody ever will.
+ Eventually(workerSess.IsClosed, "10s").Should(BeTrue())
+ })
+})
+
+var _ = Describe("Worker tunnel handler without distributed mode", func() {
+ // The route is registered in every deployment so that the route-coverage
+ // test sees it, which is what pins the reject-before-upgrade rule. With no
+ // node registry there is nothing to authenticate against, so it must refuse
+ // every dial rather than publish an unauthenticated multiplexer.
+ var srv *httptest.Server
+
+ BeforeEach(func() {
+ e := echo.New()
+ routes.RegisterWorkerTunnelRoute(e, nil, nil)
+ srv = httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+ })
+
+ It("refuses an anonymous dial with 401", func() {
+ req, err := http.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, srv.URL+clustersvc.ConnectPath, nil)
+ Expect(err).ToNot(HaveOccurred())
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("tells a credentialed worker the frontend has no cluster, rather than rejecting it", func() {
+ // A single-binary frontend cannot authenticate anybody, and saying
+ // "unauthorized" would send an operator hunting a token problem that
+ // does not exist.
+ _, resp, err := websocket.DefaultDialer.Dial(wsConnectURL(srv, "w1"), bearer(workerToken))
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusServiceUnavailable))
+ })
+})
+
+var _ = Describe("Worker tunnel auth coverage", func() {
+ // The same argument the peer link's coverage specs make: the tunnel route
+ // authenticates a worker against its own stored token, not a session, so it
+ // only works while its path sits under the prefix the global auth
+ // middleware exempts.
+ var srv *httptest.Server
+
+ BeforeEach(func() {
+ e := echo.New()
+ // A nil DB with one legacy API key is the cheapest configuration that
+ // turns the middleware ON without a database.
+ e.Use(auth.Middleware(nil, &config.ApplicationConfig{ApiKeys: []string{"an-api-key"}}))
+ routes.RegisterWorkerTunnelRoute(e, nil, nil)
+ e.GET("/api/nodes", func(c echo.Context) error { return c.NoContent(http.StatusOK) })
+ srv = httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+ })
+
+ It("refuses an uncredentialed request to a route outside the cluster prefix", func() {
+ resp, err := http.Get(srv.URL + "/api/nodes")
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
+ "the global auth middleware is not actually guarding this server, so the assertion below would prove nothing")
+ })
+
+ It("lets a worker dial reach the handler, which is the only thing that can authenticate it", func() {
+ // The worker's token is not one of the API keys the middleware knows,
+ // so a 503 from the handler's own no-cluster check can only mean the
+ // request was let through by the middleware.
+ req, err := http.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, srv.URL+clustersvc.ConnectPath+"?id=w1", nil)
+ Expect(err).ToNot(HaveOccurred())
+ req.Header.Set("Authorization", "Bearer "+workerToken)
+
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusServiceUnavailable),
+ "a worker dial must reach the handler; 401 here means the tunnel route left the auth-exempt prefix %q", auth.ClusterPathPrefix)
+ })
+})
diff --git a/core/http/endpoints/cluster/peer.go b/core/http/endpoints/cluster/peer.go
new file mode 100644
index 000000000000..9dcaea8b38de
--- /dev/null
+++ b/core/http/endpoints/cluster/peer.go
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: MIT
+
+// Package cluster serves the replica-to-replica link that a LocalAI frontend
+// uses to reach a worker tunnel it does not own. A peer dials
+// GET /api/cluster/peer, the connection becomes one multiplexed yamux session,
+// and the relay opens a stream on it per request.
+package cluster
+
+import (
+ "crypto/subtle"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/xlog"
+)
+
+// PeerHandler upgrades an authenticated peer dial to a WebSocket, wraps it as
+// a yamux server session and hands it to onSession.
+//
+// onSession runs on the request goroutine, so it must return promptly; the
+// session outlives the handler because the upgrade hijacks the connection, and
+// closing it is the caller's job.
+func PeerHandler(token string, onSession func(peerID string, sess *yamux.Session)) echo.HandlerFunc {
+ // gorilla's default CheckOrigin already restricts a browser to same-origin
+ // and lets a header-less client (which every peer is) through, so the
+ // zero value is what this link wants.
+ upgrader := websocket.Upgrader{}
+
+ return func(c echo.Context) error {
+ // Reject before upgrading. Upgrading and then closing would give the
+ // dialer a WebSocket error in place of an HTTP status, and both the
+ // route-coverage test and a peer's own retry logic read the status.
+ if !authorizedPeer(c.Request(), token) {
+ return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
+ }
+
+ // SELF-DECLARED, and knowingly so. Unlike the worker route next door,
+ // which resolves ?id= to a node row and checks that node's OWN minted
+ // credential, this route has only the shared cluster token to check,
+ // so the id is a label and not a claim anything verifies.
+ //
+ // What that costs, exactly, for anything already holding the shared
+ // token (every worker holds it, and it is the same token that
+ // authenticates registration): it can relay to every worker tunnel this
+ // replica owns, reaching every backend gRPC process and every worker's
+ // file-transfer server; by declaring a legitimate replica's id it can
+ // make SessionStore.Accept evict that replica's inbound link, at will;
+ // and it can aim the per-session receive window, which PeerLinkConfig
+ // sizes at roughly 31 GiB of unread data per session, at one replica's
+ // memory. The first two are not new capabilities in KIND - before
+ // workers stopped listening, a holder of that token could already dial
+ // any worker's advertised ports directly - but the token is now the
+ // only thing between an attacker and the whole fleet's tunnels, and the
+ // third is a figure written down as sizing guidance that is also a
+ // budget on a route this open.
+ //
+ // It is deferred rather than patched, because the cheap patch does not
+ // work: checking ?id= against the instances table stops an invented id
+ // and stops nothing else, since the attack declares a REAL replica's
+ // id, and it would buy a false sense of a closed hole. Closing it takes
+ // a credential per replica, minted where a replica joins the instances
+ // table and presented here, which is a design with its own migration
+ // and its own specs. Tracked as the phase-3 item named at
+ // nodes.BackendNode.TunnelTokenHash.
+ peerID := c.QueryParam("id")
+ if peerID == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, "missing peer id")
+ }
+
+ ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ // Upgrade has already written its own failure to the client.
+ xlog.Debug("cluster peer link upgrade failed", "peer", peerID, "error", err)
+ return nil
+ }
+
+ // Server side of the mux: the dialing peer is the client, so it owns
+ // the odd stream IDs and this side the even ones.
+ //
+ // The SAME configuration the dialler uses, and that is load bearing
+ // rather than symmetry for its own sake. A yamux receive window is
+ // advertised by the receiving side, so a nil here left this end on the
+ // 256 KiB default while the dialler ran at 4 MiB, and the direction
+ // governed by this end is the one that carries a relayed model artifact
+ // INTO the replica that owns the worker's tunnel. That direction was
+ // measured at roughly half the throughput of the same transfer without
+ // a relay in it.
+ //
+ // It also puts the same ceiling on unread data at this end that
+ // PeerLinkConfig already documents for the dialling end, so a replica
+ // is now sized against that figure per link in BOTH directions. That
+ // is the cost of the window being useful at all: a window is a bound
+ // on data received and not yet read, so a receiver that will not
+ // buffer cannot advertise one.
+ sess, err := yamux.Server(clustersvc.WebsocketConn(ws), clustersvc.PeerLinkConfig(), nil)
+ if err != nil {
+ xlog.Error("cluster peer link session setup failed", "peer", peerID, "error", err)
+ _ = ws.Close()
+ return nil
+ }
+
+ if onSession == nil {
+ // Nothing will ever read from this session, so do not leave the
+ // peer believing it has a live link.
+ _ = sess.Close()
+ return nil
+ }
+
+ xlog.Debug("cluster peer link established", "peer", peerID, "remote", ws.RemoteAddr().String())
+ // net/http recovers a panic from this goroutine but does not close a
+ // hijacked connection afterwards, so a panicking callback would leave
+ // the peer holding a link nobody accepts streams on: its opens would
+ // fill the 256-deep backlog and then hang without an error.
+ defer func() {
+ if r := recover(); r != nil {
+ _ = sess.Close()
+ panic(r)
+ }
+ }()
+ onSession(peerID, sess)
+ return nil
+ }
+}
+
+// authorizedPeer compares the request's bearer token with the cluster token in
+// constant time, matching the check the worker file-transfer server makes.
+//
+// Unlike that one, an empty configured token authorizes nobody: this route is
+// registered in every deployment, so failing open would publish an
+// unauthenticated mux to any caller that can reach the port.
+func authorizedPeer(r *http.Request, expected string) bool {
+ if expected == "" {
+ return false
+ }
+ // RFC 7235 makes the scheme case-insensitive; the token after it is not.
+ const prefix = "Bearer "
+ header := r.Header.Get("Authorization")
+ if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(header[len(prefix):]), []byte(expected)) == 1
+}
diff --git a/core/http/endpoints/cluster/peer_test.go b/core/http/endpoints/cluster/peer_test.go
new file mode 100644
index 000000000000..dc8cba3da9e6
--- /dev/null
+++ b/core/http/endpoints/cluster/peer_test.go
@@ -0,0 +1,256 @@
+package cluster_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/http/auth"
+ "github.com/mudler/LocalAI/core/http/routes"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// wsPeerURL is the peer route on a test server, named as peer-1.
+func wsPeerURL(s *httptest.Server) string {
+ return "ws" + strings.TrimPrefix(s.URL, "http") + clustersvc.PeerPath + "?id=peer-1"
+}
+
+var _ = Describe("Peer link handler", func() {
+ var (
+ srv *httptest.Server
+ sessions chan *yamux.Session
+ )
+
+ BeforeEach(func() {
+ sessions = make(chan *yamux.Session, 1)
+ e := echo.New()
+ routes.RegisterClusterRoutes(e, "peer-token", func(_ string, s *yamux.Session) {
+ sessions <- s
+ })
+ srv = httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+ })
+
+ It("rejects a connection with no token", func() {
+ _, resp, err := websocket.DefaultDialer.Dial(wsPeerURL(srv), nil)
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("rejects a connection with the wrong token", func() {
+ h := http.Header{}
+ h.Set("Authorization", "Bearer wrong")
+ _, resp, err := websocket.DefaultDialer.Dial(wsPeerURL(srv), h)
+ Expect(err).To(HaveOccurred())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("accepts an authenticated peer and yields a usable yamux session", func() {
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ conn, _, err := websocket.DefaultDialer.Dial(wsPeerURL(srv), h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ var serverSess *yamux.Session
+ Eventually(sessions, "5s").Should(Receive(&serverSess))
+ Expect(serverSess).ToNot(BeNil())
+
+ // The client wraps its side as a yamux CLIENT and opens a stream; the
+ // server must accept it. This proves the WebSocket was adapted into a
+ // stream-oriented conn correctly, which is the part most likely to be
+ // subtly wrong.
+ clientSess, err := yamux.Client(clustersvc.WebsocketConn(conn), nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = clientSess.Close() })
+
+ go func() {
+ defer GinkgoRecover()
+ st, e := clientSess.OpenStream(GinkgoT().Context())
+ if e == nil {
+ _, _ = st.Write([]byte("hello"))
+ }
+ }()
+
+ accepted := make(chan []byte, 1)
+ go func() {
+ defer GinkgoRecover()
+ st, e := serverSess.AcceptStream()
+ if e != nil {
+ return
+ }
+ buf := make([]byte, 5)
+ if _, e := st.Read(buf); e == nil {
+ accepted <- buf
+ }
+ }()
+ Eventually(accepted, "10s").Should(Receive(Equal([]byte("hello"))))
+ })
+
+ It("reports the peer id it was given", func() {
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ ids := make(chan string, 1)
+ e := echo.New()
+ routes.RegisterClusterRoutes(e, "peer-token", func(id string, _ *yamux.Session) { ids <- id })
+ s2 := httptest.NewServer(e)
+ DeferCleanup(s2.Close)
+
+ conn, _, err := websocket.DefaultDialer.Dial(wsPeerURL(s2), h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ Eventually(ids, "5s").Should(Receive(Equal("peer-1")))
+ })
+ It("rejects every dial when no cluster token is configured", func() {
+ // The route is registered in every deployment, so an empty configured
+ // token must authorize nobody. Failing open the way the worker
+ // file-transfer server's checkBearerToken does would publish an
+ // unauthenticated yamux multiplexer to anyone who can reach the port.
+ e := echo.New()
+ accepted := make(chan *yamux.Session, 1)
+ routes.RegisterClusterRoutes(e, "", func(_ string, sess *yamux.Session) { accepted <- sess })
+ s2 := httptest.NewServer(e)
+ DeferCleanup(s2.Close)
+
+ for _, header := range []http.Header{nil, {"Authorization": []string{"Bearer "}}, {"Authorization": []string{"Bearer anything"}}} {
+ _, resp, err := websocket.DefaultDialer.Dial(wsPeerURL(s2), header)
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
+ }
+ Expect(accepted).ToNot(Receive())
+ })
+
+ It("accepts the bearer scheme in any case", func() {
+ // RFC 7235 makes the scheme case-insensitive. The token after it is not.
+ h := http.Header{}
+ h.Set("Authorization", "bearer peer-token")
+ conn, _, err := websocket.DefaultDialer.Dial(wsPeerURL(srv), h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Eventually(sessions, "5s").Should(Receive())
+ })
+
+ It("closes the session when the callback panics", func() {
+ // net/http recovers the panic but leaves the hijacked socket open, so
+ // without the handler's own recover the peer would keep a link nobody
+ // ever accepts streams on.
+ e := echo.New()
+ routes.RegisterClusterRoutes(e, "peer-token", func(_ string, _ *yamux.Session) {
+ panic("callback exploded")
+ })
+ s2 := httptest.NewServer(e)
+ DeferCleanup(func() {
+ // A hijacked connection the handler never closed would park
+ // httptest's Close forever, turning the assertion below into a
+ // suite hang. Forcing the conns shut keeps the failure legible.
+ s2.CloseClientConnections()
+ s2.Close()
+ })
+
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ conn, _, err := websocket.DefaultDialer.Dial(wsPeerURL(s2), h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ clientSess, err := yamux.Client(clustersvc.WebsocketConn(conn), nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = clientSess.Close() })
+
+ // Asserting on OpenStream would hang rather than fail: yamux only
+ // acknowledges a stream once the peer accepts it, and the leak this
+ // pins is precisely that nobody ever will. The session's own liveness
+ // is the observable that answers in both directions.
+ Eventually(clientSess.IsClosed, "10s").Should(BeTrue())
+ })
+
+ It("rejects an authenticated dial that names no peer", func() {
+ // The session is keyed by peer id, so a nameless link could never be
+ // looked up again; refusing it is cheaper than leaking it.
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ _, resp, err := websocket.DefaultDialer.Dial(
+ "ws"+strings.TrimPrefix(srv.URL, "http")+"/api/cluster/peer", h)
+ Expect(err).To(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ Expect(sessions).ToNot(Receive())
+ })
+})
+
+var _ = Describe("Peer link auth coverage", func() {
+ // These specs put the REAL global auth middleware in front of the REAL
+ // registrar and prove a peer dial reaches the handler anyway. The peer link
+ // authenticates with the cluster token, not a session, so it only works
+ // while its path sits under the prefix auth exempts; moving either one
+ // alone 401s every peer dial, and the two live in packages that must not
+ // import each other.
+ //
+ // The predicate that grants the exemption is unexported, so this asserts on
+ // its effect rather than on it: what a caller can observe is whether the
+ // request reaches the handler.
+ var (
+ srv *httptest.Server
+ sessions chan *yamux.Session
+ )
+
+ BeforeEach(func() {
+ sessions = make(chan *yamux.Session, 1)
+ e := echo.New()
+ // A nil DB with one legacy API key is the cheapest configuration that
+ // turns the middleware ON without a database. With neither, Middleware
+ // short-circuits to next() and every assertion below would pass against
+ // a server that has no auth at all.
+ e.Use(auth.Middleware(nil, &config.ApplicationConfig{ApiKeys: []string{"an-api-key"}}))
+ routes.RegisterClusterRoutes(e, "peer-token", func(_ string, s *yamux.Session) { sessions <- s })
+ // A route outside the cluster prefix, registered on the same server, is
+ // the control: it proves the middleware in front of both is live.
+ e.GET("/api/nodes", func(c echo.Context) error { return c.NoContent(http.StatusOK) })
+ srv = httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+ })
+
+ It("refuses an uncredentialed request to a route outside the cluster prefix", func() {
+ resp, err := http.Get(srv.URL + "/api/nodes")
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
+ "the global auth middleware is not actually guarding this server, so the peer-route assertions below would prove nothing")
+ })
+
+ It("lets a peer dial reach the handler, which is the only thing that can authenticate it", func() {
+ // The cluster token is not one of the API keys the middleware knows, so
+ // a 400 from the handler's own missing-id check can only mean the
+ // request was let through unauthenticated by the middleware.
+ req, err := http.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, srv.URL+clustersvc.PeerPath, nil)
+ Expect(err).ToNot(HaveOccurred())
+ req.Header.Set("Authorization", "Bearer peer-token")
+
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest),
+ "a peer dial must reach the handler; 401 here means the peer route left the auth-exempt prefix %q", auth.ClusterPathPrefix)
+ })
+
+ It("completes a full peer handshake through the guarded server", func() {
+ // The status-code assertion above cannot see the upgrade, and the
+ // upgrade is what a peer actually does.
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ conn, _, err := websocket.DefaultDialer.Dial(wsPeerURL(srv), h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Eventually(sessions, "5s").Should(Receive())
+ })
+})
diff --git a/core/http/endpoints/localai/backend_logs.go b/core/http/endpoints/localai/backend_logs.go
index 6072b8483708..f2112502d253 100644
--- a/core/http/endpoints/localai/backend_logs.go
+++ b/core/http/endpoints/localai/backend_logs.go
@@ -121,6 +121,17 @@ func BackendLogsWebSocketEndpoint(ml *model.ModelLoader) echo.HandlerFunc {
conn := &backendLogsConn{Conn: ws}
+ // KNOWN RACE: the snapshot is sent before the subscription is registered, so
+ // a line appended in that window is never streamed. A viewer attaching while
+ // a model loads (when a backend is at its noisiest) can silently miss lines;
+ // they stay in the buffer, so a reload shows them. Fixing it needs an atomic
+ // snapshot-plus-subscribe held under the buffer's own lock (buf.mu in
+ // pkg/model/backend_log_store.go), because that is the lock AppendLine takes
+ // while it enqueues and fans out to subscribers. The store-level s.mu guards
+ // only the buffers map and excludes nothing an appender does, so taking it
+ // leaves this race exactly where it is. Reordering these two calls is not a
+ // fix either: it would duplicate instead of drop.
+
// Send existing lines as initial batch
existingLines := ml.BackendLogs().GetLines(modelID)
initialMsg := map[string]any{
diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go
index bbae523b1025..0571e7d7f8df 100644
--- a/core/http/endpoints/localai/nodes.go
+++ b/core/http/endpoints/localai/nodes.go
@@ -2,6 +2,7 @@ package localai
import (
"context"
+ "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
@@ -9,6 +10,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"net/http"
"net/url"
"sync"
@@ -75,10 +77,14 @@ func GetNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
// RegisterNodeRequest is the request body for registering a new worker node.
type RegisterNodeRequest struct {
- Name string `json:"name"`
- NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
- Address string `json:"address"`
- HTTPAddress string `json:"http_address,omitempty"`
+ Name string `json:"name"`
+ NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
+ // No address and no http_address. A worker has no inbound endpoint to
+ // register: it holds one outbound tunnel to a frontend replica and every
+ // protocol the frontend speaks to it travels on that. An older worker still
+ // sends both keys and they are ignored, which is the intended outcome:
+ // storing them would put a dialable-looking endpoint back in the API for
+ // something nothing dials.
Token string `json:"token,omitempty"`
TotalVRAM uint64 `json:"total_vram,omitempty"`
AvailableVRAM uint64 `json:"available_vram,omitempty"`
@@ -140,22 +146,15 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
fmt.Sprintf("invalid node_type %q; must be %q or %q", nodeType, nodes.NodeTypeBackend, nodes.NodeTypeAgent)))
}
- // Backend workers require address; agent workers don't serve gRPC
+ // A backend worker no longer has to state an address; the tunnel it
+ // dials is what makes it reachable, and requiring one here would refuse
+ // exactly the workers this design is for.
if req.Name == "" {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "name is required"))
}
- if nodeType == nodes.NodeTypeBackend && req.Address == "" {
- return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "address is required for backend workers"))
- }
if len(req.Name) > 255 {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "name exceeds 255 characters"))
}
- if len(req.Address) > 512 {
- return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "address exceeds 512 characters"))
- }
- if len(req.HTTPAddress) > 512 {
- return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "http_address exceeds 512 characters"))
- }
// Hash the token for storage (if provided)
var tokenHash string
@@ -175,8 +174,6 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
node := &nodes.BackendNode{
Name: req.Name,
NodeType: nodeType,
- Address: req.Address,
- HTTPAddress: req.HTTPAddress,
TokenHash: tokenHash,
TotalVRAM: req.TotalVRAM,
AvailableVRAM: req.AvailableVRAM,
@@ -244,6 +241,7 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
}
}
+ attachTunnelToken(ctx, response, registry, node)
attachNatsJWT(response, node, natsCfg)
return c.JSON(http.StatusCreated, response)
@@ -288,6 +286,78 @@ func ApproveNodeEndpoint(registry *nodes.NodeRegistry, authDB *gorm.DB, hmacSecr
}
}
+// attachTunnelToken mints this node a fresh tunnel credential, stores only its
+// hash, and puts the plaintext in the registration response.
+//
+// It is minted for EVERY node that registers, pending ones included, which is a
+// deliberate divergence from the two other per-node credentials in this file:
+// the agent worker's API key (provisionAgentWorkerKey) and its NATS JWT
+// (attachNatsJWT) are both withheld from a node awaiting approval. Those two
+// are bearer grants that WORK the moment they are issued, so issuing one to an
+// unapproved node would route around the admin. A tunnel credential is not:
+// core/http/endpoints/cluster/connect.go re-reads the node's status on every
+// dial and refuses a pending node with 403, so the credential is inert until an
+// admin approves and stays inert if approval is revoked. Withholding it would
+// instead strand every worker that registers exactly once (the static-NATS
+// path in core/services/worker/worker.go does), because approval alone does not
+// prompt a re-registration and nothing else can hand it the secret.
+//
+// Only BACKEND nodes get one, and that is a decision rather than an oversight.
+// An agent worker serves no gRPC backends and no file staging; nothing dials
+// into it at all, so a tunnel replaces nothing for it and there is no client on
+// the agent side that would ever open one. Minting anyway would hand out a
+// working credential for a pipe nobody drives, which is surface without a
+// feature, and it would contradict every comment in this change that says
+// "backend workers, the ones that tunnel".
+//
+// The gate lives HERE and not in ConnectHandler, which never looks at NodeType.
+// It does not need to, PROVIDED an ineligible node ends up with no credential
+// rather than merely being handed no new one, because the handler's empty-hash
+// branch is what does the refusing. So this CLEARS the column instead of
+// returning early, and the difference is not theoretical: Register upserts by
+// NAME, so a backend node re-registering as an agent keeps its ID, and
+// Register's struct Updates zero-skips TunnelTokenHash while writing the new
+// node_type. An early return left a live credential on a row that had become an
+// agent. Clearing is what makes "enforcement is structural" true.
+//
+// It clears unconditionally rather than only when something is there, so the
+// invariant holds without depending on what the row happened to contain. The
+// cost is one UPDATE per agent registration.
+//
+// The day agent workers want a tunnel, relaxing the eligibility condition is
+// the whole change, and it has to be a deliberate one.
+//
+// A failure to mint or to store is logged and the response goes out without the
+// token. Registration is what gets a worker into the cluster at all, and
+// failing it over a credential the worker does not need until it tunnels would
+// turn a tunnel problem into a node that cannot join. The worker sees no
+// tunnel_token, reports that it has no credential, and retries at its next
+// registration.
+func attachTunnelToken(ctx context.Context, response map[string]any, registry *nodes.NodeRegistry, node *nodes.BackendNode) {
+ if node == nil {
+ return
+ }
+ if node.NodeType != nodes.NodeTypeBackend {
+ // Cleared, not skipped. SetTunnelTokenHash writes the single column
+ // directly rather than through a struct update, so unlike Register it
+ // can write an empty value; see its doc.
+ if err := registry.SetTunnelTokenHash(ctx, node.ID, ""); err != nil {
+ xlog.Error("Failed to clear the tunnel credential of a node that is not a backend worker",
+ "node", node.Name, "type", node.NodeType, "error", err)
+ }
+ return
+ }
+ // crypto/rand.Text: at least 128 bits of randomness, no error to handle and
+ // no length constant to get wrong.
+ plaintext := rand.Text()
+ sum := sha256.Sum256([]byte(plaintext))
+ if err := registry.SetTunnelTokenHash(ctx, node.ID, hex.EncodeToString(sum[:])); err != nil {
+ xlog.Error("Failed to store a tunnel credential for node", "node", node.Name, "error", err)
+ return
+ }
+ response["tunnel_token"] = plaintext
+}
+
// attachNatsJWT adds a per-node NATS user JWT to a register/approve response when minting is enabled.
func attachNatsJWT(response map[string]any, node *nodes.BackendNode, natsCfg natsauth.Config) {
if !natsCfg.CanMintWorkers() || node == nil || node.Status == nodes.StatusPending {
@@ -717,7 +787,7 @@ func DeleteModelOnNodeEndpoint(unloader nodes.NodeCommandSender, registry *nodes
// NodeBackendLogsListEndpoint proxies a request to a worker node's /v1/backend-logs
// endpoint to list model IDs that have backend logs.
-func NodeBackendLogsListEndpoint(registry *nodes.NodeRegistry, registrationToken string) echo.HandlerFunc {
+func NodeBackendLogsListEndpoint(registry *nodes.NodeRegistry, registrationToken string, dialFor nodes.WorkerNetDialerFor) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
nodeID := c.Param("id")
@@ -726,11 +796,11 @@ func NodeBackendLogsListEndpoint(registry *nodes.NodeRegistry, registrationToken
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
}
- if node.HTTPAddress == "" {
- return c.JSON(http.StatusBadGateway, nodeError(http.StatusBadGateway, "node has no HTTP address"))
- }
-
- resp, err := proxyHTTPToWorker(node.HTTPAddress, "/v1/backend-logs", registrationToken)
+ // No HTTPAddress guard: a tunnel-only worker reports none, and the
+ // http stream tag ignores the target anyway. WorkerHTTPHost fills the
+ // URL's host with something that identifies the node and resolves
+ // nowhere; the tunnel decides where the bytes go.
+ resp, err := proxyHTTPToWorker(ctx, dialFor, nodeID, nodes.WorkerHTTPHost(nodeID, node.HTTPAddress), "/v1/backend-logs", registrationToken)
if err != nil {
return c.JSON(http.StatusBadGateway, nodeError(http.StatusBadGateway, fmt.Sprintf("failed to reach worker: %v", err)))
}
@@ -745,7 +815,7 @@ func NodeBackendLogsListEndpoint(registry *nodes.NodeRegistry, registrationToken
// NodeBackendLogsLinesEndpoint proxies a request to a worker node's
// /v1/backend-logs/{modelId} endpoint to get buffered log lines.
-func NodeBackendLogsLinesEndpoint(registry *nodes.NodeRegistry, registrationToken string) echo.HandlerFunc {
+func NodeBackendLogsLinesEndpoint(registry *nodes.NodeRegistry, registrationToken string, dialFor nodes.WorkerNetDialerFor) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
nodeID := c.Param("id")
@@ -756,12 +826,8 @@ func NodeBackendLogsLinesEndpoint(registry *nodes.NodeRegistry, registrationToke
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
}
- if node.HTTPAddress == "" {
- return c.JSON(http.StatusBadGateway, nodeError(http.StatusBadGateway, "node has no HTTP address"))
- }
-
path := "/v1/backend-logs/" + url.PathEscape(modelID)
- resp, err := proxyHTTPToWorker(node.HTTPAddress, path, registrationToken)
+ resp, err := proxyHTTPToWorker(ctx, dialFor, nodeID, nodes.WorkerHTTPHost(nodeID, node.HTTPAddress), path, registrationToken)
if err != nil {
return c.JSON(http.StatusBadGateway, nodeError(http.StatusBadGateway, fmt.Sprintf("failed to reach worker: %v", err)))
}
@@ -776,7 +842,7 @@ func NodeBackendLogsLinesEndpoint(registry *nodes.NodeRegistry, registrationToke
// NodeBackendLogsWSEndpoint proxies a WebSocket connection to a worker node's
// /v1/backend-logs/{modelId}/ws endpoint for real-time log streaming.
-func NodeBackendLogsWSEndpoint(registry *nodes.NodeRegistry, registrationToken string) echo.HandlerFunc {
+func NodeBackendLogsWSEndpoint(registry *nodes.NodeRegistry, registrationToken string, dialFor nodes.WorkerNetDialerFor) echo.HandlerFunc {
browserUpgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
@@ -808,15 +874,41 @@ func NodeBackendLogsWSEndpoint(registry *nodes.NodeRegistry, registrationToken s
return err
}
- // Dial the worker WebSocket
- workerURL := fmt.Sprintf("ws://%s/v1/backend-logs/%s/ws", node.HTTPAddress, url.PathEscape(modelID))
+ // Dial the worker WebSocket over that worker's tunnel. The URL still
+ // names the worker's registered address, for the Host header; the
+ // NetDialContext below is what decides where the connection goes. A
+ // missing dialer is a failure, not a direct dial: see
+ // nodes.ErrNoWorkerDialer.
+ workerURL := fmt.Sprintf("ws://%s/v1/backend-logs/%s/ws", nodes.WorkerHTTPHost(nodeID, node.HTTPAddress), url.PathEscape(modelID))
workerHeaders := http.Header{}
if registrationToken != "" {
workerHeaders.Set("Authorization", "Bearer "+registrationToken)
}
- workerDialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
- workerWS, _, err := workerDialer.Dial(workerURL, workerHeaders)
+ var workerDial func(ctx context.Context, network, addr string) (net.Conn, error)
+ if dialFor != nil {
+ workerDial = dialFor(nodeID)
+ }
+ if workerDial == nil {
+ // A JSON body cannot be written here: the response writer was
+ // hijacked by the upgrade above, so the status line is long gone
+ // and the write lands nowhere. The browser has to be told the same
+ // way every other failure past the upgrade tells it, with a close
+ // frame, and the socket has to be closed or it leaks for the life
+ // of the process.
+ // Best-effort: the browser may already have gone, and there is
+ // nothing left to report the failure to either way. The CLOSE is
+ // what matters and it is unconditional.
+ _ = browserWS.WriteMessage(websocket.CloseMessage,
+ websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "no route to worker"))
+ _ = browserWS.Close()
+ xlog.Error("Cannot stream backend logs: no way to reach the worker",
+ "node", nodeID, "error", nodes.ErrNoWorkerDialer)
+ return nil
+ }
+
+ workerDialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second, NetDialContext: workerDial}
+ workerWS, _, err := workerDialer.DialContext(ctx, workerURL, workerHeaders)
if err != nil {
browserWS.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "failed to connect to worker"))
@@ -1273,10 +1365,25 @@ func DeleteSchedulingEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
}
}
-// proxyHTTPToWorker makes a GET request to a worker's HTTP server with bearer token auth.
-func proxyHTTPToWorker(httpAddress, path, token string) (*http.Response, error) {
+// proxyHTTPToWorker makes a GET request to a worker's HTTP server with bearer
+// token auth, over that worker's tunnel.
+//
+// The URL still names the worker's registered HTTP address, because that is
+// what the Host header and every error message should say; what it no longer
+// decides is where the bytes go. dialFor supplies the transport, and a nil one
+// is an error rather than a fall back to connecting to httpAddress: a worker
+// behind NAT has no address to connect to, and a direct dial is the bypass this
+// whole change removes.
+func proxyHTTPToWorker(ctx context.Context, dialFor nodes.WorkerNetDialerFor, nodeID, httpAddress, path, token string) (*http.Response, error) {
+ if dialFor == nil {
+ return nil, fmt.Errorf("reaching node %s: %w", nodeID, nodes.ErrNoWorkerDialer)
+ }
+ dial := dialFor(nodeID)
+ if dial == nil {
+ return nil, fmt.Errorf("reaching node %s: %w", nodeID, nodes.ErrNoWorkerDialer)
+ }
reqURL := fmt.Sprintf("http://%s%s", httpAddress, path)
- req, err := http.NewRequest("GET", reqURL, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, err
}
@@ -1284,6 +1391,6 @@ func proxyHTTPToWorker(httpAddress, path, token string) (*http.Response, error)
req.Header.Set("Authorization", "Bearer "+token)
}
- client := httpclient.NewWithTimeout(15 * time.Second)
+ client := httpclient.NewWithTimeout(15*time.Second, httpclient.WithTransport(&http.Transport{DialContext: dial}))
return client.Do(req)
}
diff --git a/core/http/endpoints/localai/nodes_backends_list_test.go b/core/http/endpoints/localai/nodes_backends_list_test.go
index 636ab58b818e..c625e8e9510f 100644
--- a/core/http/endpoints/localai/nodes_backends_list_test.go
+++ b/core/http/endpoints/localai/nodes_backends_list_test.go
@@ -42,8 +42,6 @@ func (s *stubNodeCommandSender) StopBackend(_, _ string) error { return nil }
func (s *stubNodeCommandSender) UnloadModelOnNode(_, _ string) error { return nil }
-func (s *stubNodeCommandSender) PingNode(_ string) error { return nil }
-
var _ = Describe("ListBackendsOnNodeEndpoint", func() {
var registry *nodes.NodeRegistry
diff --git a/core/http/endpoints/localai/nodes_test.go b/core/http/endpoints/localai/nodes_test.go
index 19e6a6b07eea..2255116aba8c 100644
--- a/core/http/endpoints/localai/nodes_test.go
+++ b/core/http/endpoints/localai/nodes_test.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"crypto/subtle"
+ "encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -20,6 +21,12 @@ import (
. "github.com/onsi/gomega"
)
+// hashOf is how the node registry stores a secret: hex-encoded SHA-256.
+func hashOf(secret string) string {
+ sum := sha256.Sum256([]byte(secret))
+ return hex.EncodeToString(sum[:])
+}
+
var _ = DescribeTable("token validation",
func(expectedToken, providedToken string, wantMatch bool) {
if expectedToken == "" {
@@ -77,6 +84,149 @@ var _ = Describe("Node HTTP handlers", func() {
Expect(resp["status"]).To(Equal(nodes.StatusHealthy))
})
+ // register posts one registration and returns the decoded response.
+ register := func(body string, expectedToken string, autoApprove bool) map[string]any {
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ handler := RegisterNodeEndpoint(registry, expectedToken, autoApprove, nil, "", natsauth.Config{})
+ ExpectWithOffset(1, handler(c)).To(Succeed())
+ ExpectWithOffset(1, rec.Code).To(Equal(http.StatusCreated))
+
+ var resp map[string]any
+ ExpectWithOffset(1, json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
+ return resp
+ }
+
+ It("mints a per-node tunnel credential and stores only its hash", func() {
+ resp := register(`{"name":"worker-tunnel","address":"10.0.0.3:50051","token":"shared-registration-token"}`,
+ "shared-registration-token", true)
+
+ plaintext, _ := resp["tunnel_token"].(string)
+ Expect(plaintext).ToNot(BeEmpty())
+ // Not the registration token. That is the whole point: a leaked
+ // registration token plus a known node ID used to open a tunnel,
+ // because the tunnel authenticated against the hash of exactly the
+ // value every worker in the deployment holds.
+ Expect(plaintext).ToNot(Equal("shared-registration-token"))
+
+ node, err := registry.Get(context.Background(), resp["id"].(string))
+ Expect(err).ToNot(HaveOccurred())
+ // Stored as a hash, never as the secret.
+ Expect(node.TunnelTokenHash).To(Equal(hashOf(plaintext)))
+ Expect(node.TunnelTokenHash).ToNot(Equal(plaintext))
+ // And it is a DIFFERENT column from the registration token's hash,
+ // which is what the tunnel used to compare against.
+ Expect(node.TunnelTokenHash).ToNot(Equal(node.TokenHash))
+ Expect(node.TokenHash).To(Equal(hashOf("shared-registration-token")))
+
+ // The security property, which none of the above actually pins: a
+ // second node registering with the SAME shared token gets a
+ // DIFFERENT credential. Everything above is satisfied by a secret
+ // derived deterministically from the registration token, which
+ // would isolate nothing; a mutation that did exactly that passed
+ // every assertion before this one.
+ other := register(`{"name":"worker-tunnel-2","address":"10.0.0.3:50052","token":"shared-registration-token"}`,
+ "shared-registration-token", true)
+ Expect(other["tunnel_token"]).ToNot(Equal(plaintext))
+ })
+
+ It("rotates the tunnel credential on every re-registration", func() {
+ body := `{"name":"worker-rotate","address":"10.0.0.4:50051"}`
+ first := register(body, "", true)
+ second := register(body, "", true)
+
+ Expect(second["id"]).To(Equal(first["id"]), "re-registration must keep the node identity")
+ firstToken := first["tunnel_token"].(string)
+ secondToken := second["tunnel_token"].(string)
+ // Only the hash is stored, so a re-registering worker cannot be told
+ // the secret it already holds; the alternative to rotating would be
+ // storing the plaintext.
+ Expect(secondToken).ToNot(Equal(firstToken))
+
+ node, err := registry.Get(context.Background(), first["id"].(string))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.TunnelTokenHash).To(Equal(hashOf(secondToken)))
+ Expect(node.TunnelTokenHash).ToNot(Equal(hashOf(firstToken)))
+ })
+
+ It("issues a tunnel credential to a node still awaiting approval", func() {
+ // Deliberately unlike the agent API key and the NATS JWT, which are
+ // both withheld from a pending node. Those work the moment they are
+ // issued; this one does not, because the tunnel endpoint re-reads
+ // the node's status on every dial and refuses a pending node. A
+ // worker that registers exactly once would otherwise never receive
+ // one, since approval alone prompts no re-registration.
+ first := register(`{"name":"worker-pending","address":"10.0.0.5:50051","token":"shared"}`, "shared", false)
+ Expect(first["status"]).To(Equal(nodes.StatusPending))
+ plaintext, _ := first["tunnel_token"].(string)
+ Expect(plaintext).ToNot(BeEmpty())
+
+ // Non-empty alone does not pin per-node-ness, and a review's
+ // variant of the "derived from the shared token" mutation stayed
+ // green on exactly that gap. A pending node's credential has to be
+ // as unpredictable and as per-node as an approved one's, since it
+ // becomes live the moment an admin approves.
+ Expect(plaintext).ToNot(Equal("shared"))
+ node, err := registry.Get(context.Background(), first["id"].(string))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.TunnelTokenHash).To(Equal(hashOf(plaintext)))
+ Expect(node.TunnelTokenHash).ToNot(Equal(node.TokenHash))
+
+ second := register(`{"name":"worker-pending-2","address":"10.0.0.5:50052","token":"shared"}`, "shared", false)
+ Expect(second["status"]).To(Equal(nodes.StatusPending))
+ Expect(second["tunnel_token"]).ToNot(Equal(plaintext))
+ })
+
+ It("does not issue a tunnel credential to an agent node", func() {
+ // An agent worker serves no gRPC backends and no file staging;
+ // nothing dials into it, so a tunnel replaces nothing for it and no
+ // client on its side would open one. Minting anyway would be
+ // credential surface with no feature behind it.
+ //
+ // Enforcement is structural rather than a second check: with no
+ // credential minted, the node's hash stays empty and the tunnel
+ // route refuses it like any other node without one.
+ resp := register(`{"name":"agent-1","node_type":"agent"}`, "", true)
+ Expect(resp["node_type"]).To(Equal(nodes.NodeTypeAgent))
+ Expect(resp).ToNot(HaveKey("tunnel_token"))
+
+ node, err := registry.Get(context.Background(), resp["id"].(string))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.TunnelTokenHash).To(BeEmpty())
+ })
+
+ It("clears a tunnel credential when a node stops being a backend node", func() {
+ // Register upserts BY NAME, so a node can change node_type in place.
+ // Skipping the mint on the way through leaves the credential the
+ // node earned as a backend sitting on a row that is now an agent:
+ // Register's struct Updates zero-skips the column while writing the
+ // new node_type, so nothing else clears it. ConnectHandler never
+ // looks at node_type, so that stale hash is a usable tunnel
+ // credential for a node type that is not supposed to hold one.
+ //
+ // This is the same shape as the Register-upserts-by-name hazard
+ // already carried forward: a name is not an identity.
+ backend := register(`{"name":"shifty","address":"10.0.0.7:50051"}`, "", true)
+ Expect(backend["tunnel_token"]).ToNot(BeEmpty())
+
+ agent := register(`{"name":"shifty","node_type":"agent"}`, "", true)
+ Expect(agent["id"]).To(Equal(backend["id"]), "re-registration must keep the node identity")
+ Expect(agent["node_type"]).To(Equal(nodes.NodeTypeAgent))
+ Expect(agent).ToNot(HaveKey("tunnel_token"))
+
+ node, err := registry.Get(context.Background(), backend["id"].(string))
+ Expect(err).ToNot(HaveOccurred())
+ // The claim the gate makes is that an ineligible node HAS no
+ // credential, not merely that it was not handed a new one. Only
+ // then is the empty-hash refusal in ConnectHandler the enforcement.
+ Expect(node.TunnelTokenHash).To(BeEmpty(),
+ "the node kept the credential it earned as a backend, so the mint-site gate is not structural")
+ })
+
It("returns nats_jwt when account seed is configured", func() {
akp, err := nkeys.CreateAccount()
Expect(err).ToNot(HaveOccurred())
@@ -139,7 +289,11 @@ var _ = Describe("Node HTTP handlers", func() {
Expect(errObj["message"]).To(ContainSubstring("exceeds 255 characters"))
})
- It("returns 400 when address is missing for backend node type", func() {
+ It("registers a backend worker that states no address", func() {
+ // This used to be a 400. It is the shape every worker now
+ // registers with: it has no inbound endpoint, it holds one outbound
+ // tunnel, and refusing it here would refuse exactly the workers the
+ // tunnel exists for.
e := echo.New()
body := `{"name":"worker-no-addr"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
@@ -149,13 +303,35 @@ var _ = Describe("Node HTTP handlers", func() {
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
- Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ Expect(rec.Code).To(Equal(http.StatusCreated))
- var resp map[string]any
- Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
- errObj, ok := resp["error"].(map[string]any)
- Expect(ok).To(BeTrue())
- Expect(errObj["message"]).To(ContainSubstring("address is required"))
+ stored, err := registry.GetByName(context.Background(), "worker-no-addr")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored.NodeType).To(Equal(nodes.NodeTypeBackend))
+ Expect(stored.Address).To(BeEmpty())
+ Expect(stored.HTTPAddress).To(BeEmpty())
+ })
+
+ It("stores no address even when a worker still sends one", func() {
+ // An older worker keeps sending both keys. Storing them would put a
+ // dialable-looking endpoint back into the API and the Nodes page for
+ // something nothing dials, and would leave a reader of either one
+ // unsure which workers are reached how.
+ e := echo.New()
+ body := `{"name":"worker-legacy-addr","address":"10.0.0.9:50051","http_address":"10.0.0.9:50050"}`
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
+ Expect(handler(c)).To(Succeed())
+ Expect(rec.Code).To(Equal(http.StatusCreated))
+
+ stored, err := registry.GetByName(context.Background(), "worker-legacy-addr")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored.Address).To(BeEmpty())
+ Expect(stored.HTTPAddress).To(BeEmpty())
})
It("returns 400 when node_type is invalid", func() {
diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js
index c6396d5b3468..0f229d43321d 100644
--- a/core/http/react-ui/e2e/nodes-roster.spec.js
+++ b/core/http/react-ui/e2e/nodes-roster.spec.js
@@ -62,3 +62,48 @@ test.describe('Nodes roster panels', () => {
await expect(page.getByText('alpha')).toHaveCount(0)
})
})
+
+test.describe('Nodes join command', () => {
+ // The panel emits BOTH the backend and the agent join command from one
+ // component, so the bus flag has to differ per tab rather than be deleted.
+ // Backend workers connect to no NATS server; agent workers still do.
+ test('omits the NATS flag for a backend worker and keeps it for an agent worker', async ({ page }) => {
+ await mockCluster(page, [])
+ await page.goto('/app/nodes')
+
+ await page.getByRole('radio', { name: /^Backend$/ }).click()
+ const backendCli = page.locator('.p2p-cmd pre').first()
+ await expect(backendCli).toContainText('local-ai worker', { timeout: 15_000 })
+ await expect(backendCli).not.toContainText('--nats-url')
+ const backendDocker = page.locator('.p2p-cmd pre').nth(1)
+ await expect(backendDocker).not.toContainText('LOCALAI_NATS_URL')
+
+ await page.getByRole('radio', { name: /^Agent$/ }).click()
+ const agentCli = page.locator('.p2p-cmd pre').first()
+ await expect(agentCli).toContainText('local-ai agent-worker', { timeout: 15_000 })
+ await expect(agentCli).toContainText('--nats-url')
+ const agentDocker = page.locator('.p2p-cmd pre').nth(1)
+ await expect(agentDocker).toContainText('LOCALAI_NATS_URL')
+ })
+
+ test('does not advertise flags the CLI does not have', async ({ page }) => {
+ // The "How to Enable Distributed Mode" card renders ONLY on the disabled
+ // state, which the page enters when /api/nodes answers 503. Mocking a
+ // healthy cluster here would assert absence against a card that was never
+ // on the page.
+ await page.route('**/api/nodes', r => r.fulfill({ status: 503, contentType: 'application/json', body: '{}' }))
+ await page.route('**/api/nodes/models', r => r.fulfill({ status: 503, contentType: 'application/json', body: '{}' }))
+ await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 503, contentType: 'application/json', body: '{}' }))
+ await page.goto('/app/nodes')
+
+ const card = page.locator('.p2p-enable')
+ await expect(card).toBeVisible({ timeout: 15_000 })
+ // --distributed-nats and --distributed-db were never real flags; a copied
+ // command carrying them fails at kong before LocalAI does anything.
+ await expect(card).not.toContainText('--distributed-nats')
+ await expect(card).not.toContainText('--distributed-db')
+ // And the worker step no longer tells an operator to point a backend
+ // worker at a bus it does not dial.
+ await expect(card.locator('.p2p-cmd pre').nth(1)).not.toContainText('--nats-url')
+ })
+})
diff --git a/core/http/react-ui/src/components/nodes/NodePanel.jsx b/core/http/react-ui/src/components/nodes/NodePanel.jsx
index 623db00936bf..b73714ab9972 100644
--- a/core/http/react-ui/src/components/nodes/NodePanel.jsx
+++ b/core/http/react-ui/src/components/nodes/NodePanel.jsx
@@ -19,7 +19,11 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes
{node.name}
- {node.address}
+ {/* A worker has no address to show: it holds an outbound tunnel and
+ binds nothing routable. Its id is what identifies it in routing
+ logs, so that is what an operator needs here. Pre-tunnel nodes
+ may still carry an address until they re-register. */}
+ {node.address || node.id}
e.stopPropagation()}>
{node.status === 'pending' && (
diff --git a/core/http/react-ui/src/pages/NodeDetail.jsx b/core/http/react-ui/src/pages/NodeDetail.jsx
index bff7db526ad2..52d3fea1d5f2 100644
--- a/core/http/react-ui/src/pages/NodeDetail.jsx
+++ b/core/http/react-ui/src/pages/NodeDetail.jsx
@@ -78,7 +78,7 @@ export default function NodeDetail() {
navigate('/app/nodes')} className="link-plain">Cluster}
title={<> {node.name}>}
- supporting={node.address}
+ supporting={node.address || node.id}
actions={
<>
{node.status === 'draining'
diff --git a/core/http/react-ui/src/pages/Nodes.jsx b/core/http/react-ui/src/pages/Nodes.jsx
index 769371854254..8725c8477d5e 100644
--- a/core/http/react-ui/src/pages/Nodes.jsx
+++ b/core/http/react-ui/src/pages/Nodes.jsx
@@ -41,6 +41,13 @@ function WorkerHintCard({ addToast, activeTab, hasWorkers }) {
const { selected, setSelected, option, dev, setDev } = useImageSelector('cpu')
const isAgent = activeTab === 'agent'
const workerCmd = isAgent ? 'agent-worker' : 'worker'
+ // Only the agent worker still uses the bus. A backend worker reaches this
+ // frontend over one outbound tunnel and connects to no NATS server, so
+ // emitting --nats-url on its join command would tell an operator to stand up
+ // infrastructure the command does not use. Both commands come from this one
+ // panel, which is why the flag is conditional rather than deleted.
+ const natsFlag = isAgent ? ' --nats-url "nats://nats:4222" \\\n' : ''
+ const natsEnv = isAgent ? ' -e LOCALAI_NATS_URL="nats://nats:4222" \\\n' : ''
const flags = dockerFlags(option)
const flagsStr = flags ? `${flags} \\\n ` : ''
@@ -67,14 +74,14 @@ function WorkerHintCard({ addToast, activeTab, hasWorkers }) {
@@ -240,7 +247,7 @@ export default function Nodes() {
Start LocalAI with distributed mode
@@ -250,7 +257,7 @@ export default function Nodes() {
diff --git a/core/http/routes/cluster.go b/core/http/routes/cluster.go
new file mode 100644
index 000000000000..458122d036e1
--- /dev/null
+++ b/core/http/routes/cluster.go
@@ -0,0 +1,45 @@
+package routes
+
+import (
+ clusterep "github.com/mudler/LocalAI/core/http/endpoints/cluster"
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+)
+
+// RegisterClusterRoutes registers the replica-to-replica peer link. onPeer
+// receives every authenticated session; see clusterep.PeerHandler for what it
+// is expected to do with it.
+//
+// The path is core/services/cluster's own constant, so the handler and the
+// dialler cannot be registered and dialled at different paths. That the path
+// also falls under auth.ClusterPathPrefix, and so bypasses the global session
+// middleware, is asserted by driving a request through that middleware in
+// core/http/endpoints/cluster/peer_test.go.
+//
+// The route carries no auth middleware: it authenticates itself against the
+// cluster token, because a peer replica has no session and no user.
+func RegisterClusterRoutes(e *echo.Echo, token string, onPeer func(string, *yamux.Session)) {
+ e.GET(clustersvc.PeerPath, clusterep.PeerHandler(token, onPeer))
+}
+
+// RegisterWorkerTunnelRoute registers the endpoint a worker dials to open its
+// tunnel. registry authenticates the dial against the node's own stored token;
+// tunnels is what the resulting session is attached to.
+//
+// Unlike the peer link this is registered in EVERY deployment, single-binary
+// ones included, and both arguments may be nil there. Two reasons. The handler
+// fails closed without a registry, since a token can only be checked against a
+// node row and there are none; and being registered unconditionally is what
+// puts the route in front of the route-coverage test under build tag `auth`,
+// which is the thing that holds the reject-before-upgrade rule in place. A
+// route registered only in distributed mode is invisible to that test.
+//
+// Like the peer link, it carries no auth middleware and derives its path from
+// core/services/cluster's own constant, so the handler and the worker's dialler
+// cannot end up on different paths.
+func RegisterWorkerTunnelRoute(e *echo.Echo, registry *nodes.NodeRegistry, tunnels *clustersvc.TunnelRegistry) {
+ e.GET(clustersvc.ConnectPath, clusterep.ConnectHandler(registry, tunnels))
+}
diff --git a/core/http/routes/nodes.go b/core/http/routes/nodes.go
index 053d6c19cf30..a511c6b7c1fc 100644
--- a/core/http/routes/nodes.go
+++ b/core/http/routes/nodes.go
@@ -61,7 +61,13 @@ func RegisterNodeSelfServiceRoutes(e *echo.Echo, registry *nodes.NodeRegistry, r
// backend install path (POST /:id/backends/install). That handler enqueues a
// ManagementOp on the gallery channel rather than blocking on a NATS reply, so
// the browser gets HTTP 202 + jobID immediately instead of waiting up to 3 minutes.
-func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloader nodes.NodeCommandSender, galleryService *galleryop.GalleryService, opcache *galleryop.OpCache, appConfig *config.ApplicationConfig, adminMw echo.MiddlewareFunc, authDB *gorm.DB, hmacSecret string, registrationToken string, natsCfg natsauth.Config) {
+//
+// workerDialFor is how the log-proxy routes reach a worker's own HTTP server:
+// over the tunnel that worker holds, never by connecting to the address it
+// registered. It is nil outside distributed mode, and those two routes then
+// answer 502 rather than dialling, because a worker with no tunnel has nothing
+// for them to proxy to.
+func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloader nodes.NodeCommandSender, galleryService *galleryop.GalleryService, opcache *galleryop.OpCache, appConfig *config.ApplicationConfig, adminMw echo.MiddlewareFunc, authDB *gorm.DB, hmacSecret string, registrationToken string, natsCfg natsauth.Config, workerDialFor nodes.WorkerNetDialerFor) {
if registry == nil {
return
}
@@ -101,8 +107,8 @@ func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloade
admin.POST("/:id/models/delete", localai.DeleteModelOnNodeEndpoint(unloader, registry))
// Backend log streaming (proxied from worker HTTP server)
- admin.GET("/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, registrationToken))
- admin.GET("/:id/backend-logs/:modelId", localai.NodeBackendLogsLinesEndpoint(registry, registrationToken))
+ admin.GET("/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, registrationToken, workerDialFor))
+ admin.GET("/:id/backend-logs/:modelId", localai.NodeBackendLogsLinesEndpoint(registry, registrationToken, workerDialFor))
// Label management
admin.GET("/:id/labels", localai.GetNodeLabelsEndpoint(registry))
@@ -123,7 +129,7 @@ func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloade
admin.DELETE("/:id/vram-budget", localai.ResetVRAMBudgetEndpoint(registry))
// WebSocket proxy for real-time log streaming from workers
- e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, registrationToken), readyMw, adminMw)
+ e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, registrationToken, workerDialFor), readyMw, adminMw)
}
// nodeTokenAuth validates the registration token for node self-service endpoints.
diff --git a/core/services/advisorylock/advisorylock_test.go b/core/services/advisorylock/advisorylock_test.go
index f1bd3e75ed5c..536666f6cb5e 100644
--- a/core/services/advisorylock/advisorylock_test.go
+++ b/core/services/advisorylock/advisorylock_test.go
@@ -2,7 +2,9 @@ package advisorylock
import (
"context"
+ "fmt"
"runtime"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -14,6 +16,51 @@ import (
"gorm.io/gorm"
)
+// alterThisDatabase applies a server-side setting to the database this handle is
+// actually connected to, and proves it landed.
+//
+// The name is read back from the connection rather than written as a literal.
+// The test helper hands each spec its own database on a shared server, so a
+// hard-coded name ALTERs a database this handle never touches: the statement
+// succeeds, the override does nothing, and the two specs below go green having
+// exercised none of the condition they exist for. They regress a model-load
+// advisory-lock wedge that has already shipped to production once, so a green
+// spec that proves nothing is the worst outcome available here.
+//
+// The read-back is the guard. Idle connections are dropped first so the next one
+// is opened fresh and inherits the new database-level default; SHOW then reports
+// what a waiter's own connection would inherit. If that ever stops matching, the
+// spec fails here rather than passing for the wrong reason.
+func alterThisDatabase(db *gorm.DB, setting, value string) {
+ GinkgoHelper()
+
+ var name string
+ Expect(db.Raw("SELECT current_database()").Scan(&name).Error).ToNot(HaveOccurred())
+ Expect(name).ToNot(BeEmpty())
+
+ Expect(db.Exec(fmt.Sprintf("ALTER DATABASE %q SET %s = %s", name, setting, quoteLiteral(value))).Error).
+ ToNot(HaveOccurred())
+
+ sqlDB, err := db.DB()
+ Expect(err).ToNot(HaveOccurred())
+ // database/sql retains no idle connections at 0, closing the ones it is
+ // already holding, so every connection after this point is opened fresh and
+ // inherits the new database-level default.
+ sqlDB.SetMaxIdleConns(0)
+
+ var applied string
+ Expect(db.Raw("SHOW " + setting).Scan(&applied).Error).ToNot(HaveOccurred())
+ Expect(applied).To(Equal(value),
+ "the %s override did not reach the database this spec is holding (%s), so the spec below would pass without ever reproducing the condition it regresses",
+ setting, name)
+}
+
+// quoteLiteral wraps a settings value as a SQL string literal. The values here
+// are spec constants, so this only has to be correct, not hostile-input-proof.
+func quoteLiteral(v string) string {
+ return "'" + strings.ReplaceAll(v, "'", "''") + "'"
+}
+
var _ = Describe("AdvisoryLock", func() {
Context("PostgreSQL advisory locks", func() {
var db *gorm.DB
@@ -166,12 +213,7 @@ var _ = Describe("AdvisoryLock", func() {
// blocked on pg_advisory_lock() is aborted by the server after this
// window and surfaces SQLSTATE 55P03 ("canceling statement due to
// lock timeout") to the caller instead of waiting for its turn.
- Expect(db.Exec("ALTER DATABASE testdb SET lock_timeout = '300ms'").Error).ToNot(HaveOccurred())
- sqlDB, err := db.DB()
- Expect(err).ToNot(HaveOccurred())
- // Drop pooled connections so subsequent ones reconnect and inherit
- // the new database-level lock_timeout default.
- sqlDB.SetMaxIdleConns(0)
+ alterThisDatabase(db, "lock_timeout", "300ms")
holding := make(chan struct{})
released := make(chan struct{})
@@ -214,12 +256,7 @@ var _ = Describe("AdvisoryLock", func() {
// statement_timeout=60s; a cold model load holds the lock far longer,
// so every concurrent caller died with SQLSTATE 57014 ("canceling
// statement due to statement timeout") rather than waiting its turn.
- Expect(db.Exec("ALTER DATABASE testdb SET statement_timeout = '300ms'").Error).ToNot(HaveOccurred())
- sqlDB, err := db.DB()
- Expect(err).ToNot(HaveOccurred())
- // Drop pooled connections so subsequent ones reconnect and inherit
- // the new database-level statement_timeout default.
- sqlDB.SetMaxIdleConns(0)
+ alterThisDatabase(db, "statement_timeout", "300ms")
holding := make(chan struct{})
released := make(chan struct{})
diff --git a/core/services/cluster/cluster_suite_test.go b/core/services/cluster/cluster_suite_test.go
new file mode 100644
index 000000000000..d821487298fc
--- /dev/null
+++ b/core/services/cluster/cluster_suite_test.go
@@ -0,0 +1,13 @@
+package cluster_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestCluster(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Cluster Package Suite")
+}
diff --git a/core/services/cluster/dialer.go b/core/services/cluster/dialer.go
new file mode 100644
index 000000000000..cba17d206f03
--- /dev/null
+++ b/core/services/cluster/dialer.go
@@ -0,0 +1,423 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/mudler/xlog"
+)
+
+// PeerOpener opens a stream to another frontend replica. *PeerPool is the
+// production implementation.
+//
+// It is an interface so a spec can decide what a peer does without standing up
+// a second frontend. Note that a TYPED nil (a (*PeerPool)(nil) stored here)
+// would not compare equal to nil and would be called; the dialer's nil check is
+// for the untyped nil a deployment with no peer mesh passes.
+type PeerOpener interface {
+ Open(ctx context.Context, peerID string) (net.Conn, error)
+}
+
+// ErrNoRelayPath reports that a worker's tunnel is held by ANOTHER replica and
+// this one has no way to reach that replica.
+//
+// It is its own condition, kept out of the four this phase refuses to collapse.
+// Not ErrNotOwner, because that tells a caller to resolve the owner again and
+// the answer would not change. Not ErrPeerUnreachable, because no peer was
+// dialled and none refused; saying otherwise would blame a replica that is
+// probably fine. And above all not ErrNoConnection: the worker IS connected,
+// and core/services/nodes reclaims the models of a worker it believes absent.
+var ErrNoRelayPath = errors.New("cluster: this replica cannot relay to the owner of that worker")
+
+// ErrNoRoute reports that this replica could not get a request to a worker's
+// backend, and is the FIFTH condition this phase keeps apart.
+//
+// It says nothing about whether the worker exists or is running. A worker's
+// PRESENCE is its heartbeat, which lives in core/services/nodes and which this
+// package cannot see; what this package can see is whether a route exists right
+// now, and those are different questions with different answers. A worker that
+// is registered, heartbeating and serving models can be unroutable from here
+// for a whole list of ordinary reasons: it has not dialled its tunnel yet after
+// a frontend-first upgrade, the replica holding its tunnel is restarting, the
+// ownership row is a moment stale, this replica has no peer mesh.
+//
+// Every failure to RESOLVE OR OPEN a route carries it, so a consumer that must
+// not act on absence has exactly one check to make. The specific condition
+// stays in the unwrap chain underneath for anyone that can act on it, with one
+// deliberate exception: see routeFailure.
+//
+// It is not carried by a REFUSAL from the worker itself. A worker that answers
+// is present by demonstration, and folding its answer into "no route" would
+// throw away the one thing on this path that is real evidence.
+var ErrNoRoute = errors.New("cluster: no route from this replica to that worker")
+
+// noRouteError reports a worker this replica cannot route to, keeping the cause
+// in its message and OUT of its unwrap chain.
+//
+// Withholding the cause is the entire point, and it is the same guarantee
+// unreachableError makes for peers. The causes this is built over are absence
+// claims: ErrNoConnection ("no live replica holds this worker's tunnel") and
+// ErrInstanceNotFound ("no such frontend replica"). Both are true statements
+// about the CLUSTER and neither is a statement about the worker, but a consumer
+// matching on them would read them as one, and the consequence is the
+// catastrophe this phase is built around: a scheduler concludes a worker that
+// is heartbeating and serving has gone away, and reclaims its models.
+//
+// The guarantee therefore belongs to the type. There is no path by which an
+// absence sentinel gets out, so no call site can leak one.
+type noRouteError struct {
+ nodeID string
+ cause error
+}
+
+func (e *noRouteError) Error() string {
+ return fmt.Sprintf("cluster: no route from this replica to node %q: %v", e.nodeID, e.cause)
+}
+
+// Unwrap reports only ErrNoRoute. The cause reaches a human through Error() and
+// reaches no error-matching caller at all.
+func (e *noRouteError) Unwrap() error { return ErrNoRoute }
+
+// routeFailure is the ONE place a Dial failure is turned into an error, and the
+// one place the absence rule is expressed.
+//
+// The rule: an absence claim never reaches a caller, and everything else stays
+// matchable. It is a single predicate in a single function on purpose. An
+// earlier shape in this phase encoded one policy in two predicates, and
+// reverting either left the suite green because the error reached the same
+// answer down the other path; a rule whose correctness argument IS its mutation
+// evidence cannot afford to be un-mutatable in pieces. Falsifying either half
+// of isAbsenceClaim now reddens a named spec.
+func routeFailure(nodeID string, cause error) error {
+ if isAbsenceClaim(cause) {
+ return &noRouteError{nodeID: nodeID, cause: cause}
+ }
+ return fmt.Errorf("reaching node %q: %w: %w", nodeID, ErrNoRoute, cause)
+}
+
+// isAbsenceClaim reports whether an error asserts that something does not
+// exist. Those are the errors routeFailure keeps out of the chain.
+//
+// Both are about the CLUSTER rather than about the worker. ErrNoConnection says
+// no live replica holds the worker's tunnel; ErrInstanceNotFound says a peer
+// replica is not in the deployment. Neither can be answered by this package
+// with "and therefore the worker is gone", because this package does not know
+// what a worker is beyond an id in a connection row.
+func isAbsenceClaim(err error) bool {
+ return errors.Is(err, ErrNoConnection) || errors.Is(err, ErrInstanceNotFound)
+}
+
+// IsWorkerAnswer reports whether an error is the WORKER's own refusal, read off
+// the reply it sent.
+//
+// Those three sentinels are the only ones ReadStreamReply produces from a frame
+// the worker actually wrote. Everything else it returns is a failure to read
+// one, which is the tunnel breaking rather than the worker speaking.
+//
+// ErrStreamNotServed is deliberately NOT here, even though it is the fourth
+// refusal and a worker plainly sent it. It is the code a worker uses to say it
+// learned nothing: a request frame that never arrived in time, a stream whose
+// deadline could not be armed, anything it could not classify. Those clear on
+// their own, so they must reach a consumer as "no route" and cost a retry, not
+// a row. Keeping the fourth code out of this predicate is what makes it
+// possible for the worker to answer honestly at all.
+//
+// A reply carrying a code this frontend does not recognise is NOT counted here
+// either, for the same reason at the next version boundary. Classifying it as
+// an answer would let a newer worker's vocabulary be read by an older frontend
+// as evidence about a backend, and the consequence of guessing wrong in that
+// direction is a reaped replica; guessing wrong the other way costs a retry.
+//
+// It is EXPORTED because it is half of a contract, not an implementation
+// detail. Dial keeps these three out of the ErrNoRoute umbrella so that a
+// consumer can act on them; a consumer that cannot ask "was this the worker
+// speaking?" has no way to use that, and for a whole phase none could, so every
+// worker refusal reached the schedulers as "this frontend has no route" and
+// nothing could ever be reaped. The two sides must agree on the SAME set, so
+// there is one predicate and both call it: see nodes.unroutable and
+// model.transportFailure, whose job is to answer "did this call reach a
+// backend?" and for whom a refusal means it did.
+func IsWorkerAnswer(err error) bool {
+ // Read off the vocabulary table rather than enumerated here, so this
+ // predicate and the wire codes cannot disagree about which refusals exist.
+ // Enumerating them by hand is what let a fifth site promote the fourth code
+ // into a verdict; see streamRefusals.
+ for _, r := range streamRefusals {
+ if r.evidence && errors.Is(err, r.sentinel) {
+ return true
+ }
+ }
+ return false
+}
+
+// dialHandshakeTimeout bounds the request/reply exchange that opens every
+// stream, when the caller stated no deadline of its own.
+//
+// It is a backstop and not a budget. A worker or a relay that accepted a stream
+// and then said nothing would otherwise park the caller until the session's
+// keepalive killed it, which is 30 seconds on the yamux default and longer if a
+// deployment ever raises it. Where the caller DOES carry a deadline, that
+// deadline is used instead whenever it is the shorter of the two, for the same
+// reason the relay takes the smaller of its ceiling and the stated budget.
+const dialHandshakeTimeout = 15 * time.Second
+
+// WorkerDialer opens connections to a worker through its tunnel, wherever in
+// the deployment that tunnel happens to be held.
+//
+// It is the single door: a worker holds ONE tunnel, it lands on ONE frontend
+// replica, and nothing else in the frontend may dial a worker's advertised
+// address. Every protocol the frontend speaks to a worker (gRPC to a backend
+// process, HTTP for file staging and logs, a WebSocket for log streaming) goes
+// through the functions below, because a worker behind NAT has no address to
+// dial and a worker that has one must not be reached that way either: a direct
+// dial works in a single-replica test and fails in production.
+type WorkerDialer struct {
+ // Both are read off the tunnel registry rather than passed separately, so
+ // the identity this dialer compares owners against is by construction the
+ // identity the registry CLAIMS as. Two ids here would make this replica
+ // relay to itself for every worker it holds.
+ tunnels *TunnelRegistry
+ peers PeerOpener
+}
+
+// NewWorkerDialer returns the dialer for the tunnels this replica holds and the
+// peer links it can relay over. A nil peers means this replica cannot relay,
+// which is reported as ErrNoRelayPath rather than as a worker being absent.
+func NewWorkerDialer(tunnels *TunnelRegistry, peers PeerOpener) *WorkerDialer {
+ return &WorkerDialer{tunnels: tunnels, peers: peers}
+}
+
+// Dial opens one stream to a local service on a worker: tag says which service
+// (see StreamTagGRPC and StreamTagHTTP) and target which instance of it.
+//
+// The returned conn is past both handshakes and carries the tunnelled protocol
+// and nothing else, with no deadline armed on it: what follows may be an
+// inference that is quiet for minutes, and a deadline left over from the
+// handshake would abort it.
+//
+// EVERY failure to resolve or open a route carries ErrNoRoute, and NO failure
+// carries an absence sentinel. That pair is the contract, and it is what makes
+// this safe to consume from a package that reclaims a worker's models when it
+// decides the worker has gone: there is one check to make, and there is nothing
+// to mistake for absence even if the caller makes none.
+//
+// Underneath the umbrella the conditions stay apart and a caller may act on
+// them differently. ErrNotOwner means the routing was stale and re-resolving
+// may find it; ErrPeerUnreachable means a replica would not answer;
+// ErrNoRelayPath means none could be dialled. A refusal from the WORKER carries
+// its own tunnelproto sentinel and no umbrella at all, because a worker that
+// answers has demonstrated it is there.
+func (d *WorkerDialer) Dial(ctx context.Context, nodeID, tag, target string) (net.Conn, error) {
+ stream, err := d.tunnels.Open(ctx, nodeID)
+ if err == nil {
+ return d.handshake(ctx, stream, nodeID, tag, target)
+ }
+ if !errors.Is(err, ErrNotOwner) {
+ // The tunnel is held HERE and its session would not carry a stream.
+ // ErrNotOwner stays out of it: that answer would send the caller to
+ // resolve an owner which is this same replica.
+ return nil, routeFailure(nodeID, err)
+ }
+ return d.relay(ctx, nodeID, tag, target)
+}
+
+// DialerFor returns a net.Dialer-shaped function bound to one worker and one
+// local service on it.
+//
+// This is the shape http.Transport.DialContext and websocket.Dialer's
+// NetDialContext want. The NETWORK is ignored and the ADDRESS becomes the
+// stream's target, which is what makes an http.Client or a WebSocket dialler
+// built on it reach the worker without either of them knowing a tunnel exists:
+// the URL still names the worker's registered address, and that address travels
+// as the target rather than to a socket. What the worker does with it is the
+// worker's decision (the grpc tag takes the port and dials its own loopback,
+// the http tag ignores it entirely), which is the property that stops a
+// frontend from steering a worker's dial.
+func (d *WorkerDialer) DialerFor(nodeID, tag string) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ return func(ctx context.Context, _, addr string) (net.Conn, error) {
+ return d.Dial(ctx, nodeID, tag, addr)
+ }
+}
+
+// GRPCDialerFor returns a grpc.WithContextDialer-shaped function bound to one
+// worker's backend processes. gRPC's dialer takes no network argument, which is
+// why this is not DialerFor's shape.
+func (d *WorkerDialer) GRPCDialerFor(nodeID string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(ctx context.Context, addr string) (net.Conn, error) {
+ return d.Dial(ctx, nodeID, StreamTagGRPC, addr)
+ }
+}
+
+// relay opens the stream through the replica that holds the worker's tunnel.
+func (d *WorkerDialer) relay(ctx context.Context, nodeID, tag, target string) (net.Conn, error) {
+ // Owner, never OwnerRow: the row outlives its owner by up to a liveness
+ // window plus a heartbeat, and dialling what the unjoined read returns
+ // means dialling a process that is gone and reporting the worker as
+ // unreachable rather than as absent. The join is what makes a dead owner
+ // come back as ErrNoConnection here.
+ owner, _, err := d.tunnels.reg.Owner(ctx, nodeID)
+ if err != nil {
+ // ErrNoConnection is the ordinary answer here, and it is precisely the
+ // one that must not get out: it means no live replica holds this
+ // worker's tunnel, which a worker that has not dialled in yet produces
+ // on every single request while it sits there heartbeating and serving.
+ // routeFailure keeps it in the message and out of the chain.
+ return nil, routeFailure(nodeID, err)
+ }
+ if owner == d.tunnels.selfID {
+ // The table names this replica and the registry above said the tunnel
+ // is not held here, so the attachment went away between the claim and
+ // now. Relaying would send the request into this same process, which
+ // would resolve the same owner and relay again. Reported as the routing
+ // fact so the caller re-resolves, which terminates: the row is either
+ // re-claimed by whoever holds the worker now, or swept.
+ return nil, routeFailure(nodeID, fmt.Errorf("the connection row names this replica, which no longer holds the tunnel: %w", ErrNotOwner))
+ }
+ if d.peers == nil {
+ return nil, routeFailure(nodeID, fmt.Errorf("the tunnel is held by replica %q: %w", owner, ErrNoRelayPath))
+ }
+
+ stream, err := d.peers.Open(ctx, owner)
+ if err != nil {
+ // ErrPeerUnreachable and ErrPoolClosed keep their identity; the one
+ // case the pool can also produce, ErrInstanceNotFound for an owner
+ // swept between the lookup above and this dial, is an absence claim
+ // about the REPLICA and routeFailure withholds it. Either way nothing
+ // here is a statement about the worker.
+ return nil, routeFailure(nodeID, fmt.Errorf("through replica %q: %w", owner, err))
+ }
+
+ // The caller's remaining time, stated so the owning replica can bound its
+ // own open by it. Only this side knows it; see relayOpenTimeout for what
+ // the owner falls back to without it.
+ if err := WriteRelayRequest(stream, nodeID, remainingBudget(ctx)); err != nil {
+ _ = stream.Close()
+ return nil, routeFailure(nodeID, fmt.Errorf("naming the node on a stream to replica %q: %w", owner, err))
+ }
+ if err := ReadRelayReply(stream); err != nil {
+ // A refusal from the OWNING REPLICA, not from the worker. It says the
+ // owner would not relay, which is a route that does not exist, so the
+ // umbrella is right for all of them; ErrNotOwner, ErrRelayUnavailable
+ // and ErrRelayRequestInvalid stay in the chain underneath.
+ _ = stream.Close()
+ return nil, routeFailure(nodeID, fmt.Errorf("through replica %q: %w", owner, err))
+ }
+ return d.handshake(ctx, stream, nodeID, tag, target)
+}
+
+// handshake names the worker-side service on a stream and waits for the
+// worker's answer, leaving the stream ready for the tunnelled protocol.
+//
+// It owns closing the stream on every failure. A stream left open after a
+// failed handshake holds a yamux slot on the tunnel for the life of the
+// session, and a frontend that retries would exhaust the worker's stream
+// budget rather than the worker's patience.
+func (d *WorkerDialer) handshake(ctx context.Context, stream net.Conn, nodeID, tag, target string) (net.Conn, error) {
+ // blameCaller attributes a handshake I/O failure to the CALLER's own spent
+ // budget when that is what ended it, and returns nil when it was not.
+ //
+ // The third instance of the rule peerlink.go states in full at callerRanOut:
+ // the handshake deadline IS the caller's deadline whenever the caller's is
+ // the shorter (see handshakeDeadline), so a caller that has run out makes
+ // the socket's own timer fire, and the resulting i/o timeout arrives here
+ // while ctx.Err() may still read nil because nothing orders the two timers.
+ // Reported plainly, that is "the tunnel would not carry the request" for a
+ // worker that is connected, healthy and idle.
+ //
+ // The umbrella stays on either way, so Dial's contract is unchanged; what
+ // changes is that context.DeadlineExceeded is matchable underneath and the
+ // log line names the caller instead of the worker. It deliberately runs
+ // BEFORE the worker-answer check below, so a refusal that arrived in the
+ // same instant the budget expired is reported as the caller's timeout: a
+ // spent deadline must never be able to manufacture evidence about a
+ // backend, which is the same direction peerlink.go takes for absence.
+ blameCaller := func(what string) error {
+ ctxErr := callerRanOut(ctx)
+ if ctxErr == nil {
+ return nil
+ }
+ return routeFailure(nodeID, fmt.Errorf("%s: the caller's own budget ran out: %w", what, ctxErr))
+ }
+
+ if err := stream.SetDeadline(handshakeDeadline(ctx)); err != nil {
+ _ = stream.Close()
+ return nil, routeFailure(nodeID, fmt.Errorf("arming the handshake deadline: %w", err))
+ }
+
+ if err := WriteStreamRequest(stream, tag, target); err != nil {
+ // The stream would not carry the request, so the tunnel broke under it.
+ // Nothing was asked of the worker and nothing was learned about it.
+ _ = stream.Close()
+ if blamed := blameCaller(fmt.Sprintf("asking for %q on %q", tag, target)); blamed != nil {
+ return nil, blamed
+ }
+ return nil, routeFailure(nodeID, fmt.Errorf("asking for %q on %q: %w", tag, target, err))
+ }
+ if err := ReadStreamReply(stream); err != nil {
+ _ = stream.Close()
+ if blamed := blameCaller(fmt.Sprintf("opening %q", tag)); blamed != nil {
+ return nil, blamed
+ }
+ if IsWorkerAnswer(err) {
+ // The worker wrote a refusal, so it is connected and answering.
+ // This is the ONE failure on the whole path that is real evidence
+ // about the worker, and putting the umbrella on it would throw that
+ // away.
+ return nil, fmt.Errorf("opening %q on node %q: %w", tag, nodeID, err)
+ }
+ return nil, routeFailure(nodeID, fmt.Errorf("opening %q: %w", tag, err))
+ }
+
+ // Cleared unconditionally rather than only when one was armed, so that this
+ // stays true of the stream whatever the caller's context carried. What
+ // follows is the caller's protocol, and its length is the caller's
+ // business: a request may sit quiet for minutes between tokens, and the
+ // handshake's deadline would end it. The session's keepalive is what still
+ // bounds a peer that has stopped answering.
+ if err := stream.SetDeadline(time.Time{}); err != nil {
+ _ = stream.Close()
+ return nil, routeFailure(nodeID, fmt.Errorf("clearing the handshake deadline: %w", err))
+ }
+ xlog.Debug("opened a tunnelled stream to a worker", "node", nodeID, "tag", tag, "target", target)
+ return stream, nil
+}
+
+// handshakeDeadline is when the handshake must be done by: the caller's own
+// deadline when it has one and it is the sooner, and the backstop otherwise.
+//
+// There is always one, which is why this returns no "was there one" flag: a
+// context with no deadline still gets the backstop, so the caller has nothing
+// to branch on. It used to return a bool that was unconditionally true, and the
+// branch behind it could not be taken.
+func handshakeDeadline(ctx context.Context) time.Time {
+ backstop := time.Now().Add(dialHandshakeTimeout)
+ deadline, ok := ctx.Deadline()
+ if !ok || deadline.After(backstop) {
+ return backstop
+ }
+ return deadline
+}
+
+// remainingBudget is how long the caller is still willing to wait, or zero when
+// it did not say.
+//
+// Zero rather than a negative number for an expired context: the frame writer
+// treats zero as "not stated", and a caller that has already run out is about
+// to fail on its own context anyway. Stating a negative budget would instead
+// make the owning replica refuse, which is the same outcome by a longer route.
+func remainingBudget(ctx context.Context) time.Duration {
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ return 0
+ }
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return 0
+ }
+ return remaining
+}
diff --git a/core/services/cluster/dialer_test.go b/core/services/cluster/dialer_test.go
new file mode 100644
index 000000000000..8edd28e5215e
--- /dev/null
+++ b/core/services/cluster/dialer_test.go
@@ -0,0 +1,729 @@
+// SPDX-License-Identifier: MIT
+
+package cluster_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+// stubPeers is a PeerOpener that hands back streams on one session, or one
+// error. It stands in for the replica-to-replica link so a spec can decide what
+// a peer does without standing up a second frontend.
+type stubPeers struct {
+ sess *yamux.Session
+ err error
+
+ // opened records the peers this pool was asked for, so a spec can assert
+ // that a replica was NOT dialled. That is the only way to tell a dialer
+ // that resolved a live owner from one that resolved a dead row and then
+ // found out the hard way.
+ mu sync.Mutex
+ opened []string
+}
+
+func (s *stubPeers) Open(ctx context.Context, peerID string) (net.Conn, error) {
+ s.mu.Lock()
+ s.opened = append(s.opened, peerID)
+ s.mu.Unlock()
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.sess.OpenStream(ctx)
+}
+
+func (s *stubPeers) peersDialled() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]string(nil), s.opened...)
+}
+
+// dialResult carries what a Dial produced, so a spec can wait on a channel
+// rather than on a clock.
+type dialResult struct {
+ conn net.Conn
+ err error
+}
+
+// dialAsync runs one Dial on its own goroutine. Dial talks to a worker that a
+// spec drives by hand, so the spec has to be free to answer while the dial is
+// still in flight.
+func dialAsync(d *cluster.WorkerDialer, ctx context.Context, nodeID, tag, target string) chan dialResult {
+ done := make(chan dialResult, 1)
+ go func() {
+ defer GinkgoRecover()
+ conn, err := d.Dial(ctx, nodeID, tag, target)
+ done <- dialResult{conn: conn, err: err}
+ }()
+ return done
+}
+
+// relayRequest is what an owning replica saw in the frame that opened a
+// relayed stream.
+type relayRequest struct {
+ nodeID string
+ budget time.Duration
+ err error
+}
+
+// servedRequest is what a worker saw on a stream opened through the tunnel.
+type servedRequest struct {
+ tag string
+ target string
+ stream net.Conn
+ err error
+}
+
+// serveOneStream accepts one stream on the worker's half, reads the tunnel
+// request frame and accepts it, then echoes the four bytes it is sent. It is
+// how a spec proves the dial produced a stream that carries the tunnelled
+// protocol, and that the frame the worker sees is the one the dialer wrote.
+func serveOneStream(worker *yamux.Session) chan servedRequest {
+ seen := make(chan servedRequest, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ seen <- servedRequest{err: err}
+ return
+ }
+ tag, target, err := cluster.ReadStreamRequest(stream)
+ if err != nil {
+ seen <- servedRequest{err: err}
+ return
+ }
+ if err := cluster.WriteStreamAccepted(stream); err != nil {
+ seen <- servedRequest{err: err}
+ return
+ }
+ seen <- servedRequest{tag: tag, target: target, stream: stream}
+ buf := make([]byte, 4)
+ if _, err := io.ReadFull(stream, buf); err != nil {
+ return
+ }
+ _, _ = stream.Write(buf)
+ }()
+ return seen
+}
+
+// refuseOneStream accepts one stream and refuses it with reason.
+func refuseOneStream(worker *yamux.Session, reason error) {
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ if _, _, err := cluster.ReadStreamRequest(stream); err != nil {
+ return
+ }
+ _ = cluster.WriteStreamRefusal(stream, reason)
+ }()
+}
+
+// expectNotAbsence asserts an error is none of the sentinels a caller is
+// entitled to act on as "this worker has gone away".
+//
+// It is the assertion this whole phase turns on. core/services/nodes reclaims a
+// worker's models when it concludes the worker is absent, so an unreachable
+// peer, a stale ownership row or a worker that has not dialled its tunnel yet
+// arriving as absence would evict healthy work.
+func expectNotAbsence(err error) {
+ GinkgoHelper()
+ Expect(err).To(HaveOccurred())
+ Expect(err).ToNot(MatchError(cluster.ErrNoConnection))
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound))
+}
+
+// expectNoRoute asserts the umbrella is present as well as absence being gone.
+//
+// The umbrella is what crosses the package boundary. A consumer that reclaims
+// models has one check to make, and it can only make it if EVERY failure to
+// resolve or open a route carries it; a single path that forgets is a path
+// where a live worker gets reaped.
+func expectNoRoute(err error) {
+ GinkgoHelper()
+ expectNotAbsence(err)
+ Expect(err).To(MatchError(cluster.ErrNoRoute))
+}
+
+var _ = Describe("The worker dialer", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ mine *cluster.TunnelRegistry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ mine = cluster.NewTunnelRegistry(reg, "me")
+ })
+
+ // ownerRelay stands up a SECOND replica that holds w1's tunnel and relays
+ // for it, and returns the peer opener this replica reaches it through plus
+ // the worker's own half of the tunnel.
+ ownerRelay := func(nodeID string) (*stubPeers, *yamux.Session) {
+ GinkgoHelper()
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ ownerTunnels := cluster.NewTunnelRegistry(reg, "owner")
+ frontend, worker := workerTunnel()
+ _, err := ownerTunnels.Attach(ctx, nodeID, frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ store := cluster.NewSessionStore(cluster.NewRelay(ownerTunnels).Stream)
+ DeferCleanup(store.CloseAll)
+ dialling, accepted := yamuxPair()
+ store.Accept("me", accepted)
+ return &stubPeers{sess: dialling}, worker
+ }
+
+ Describe("when this replica holds the tunnel", func() {
+ It("opens a stream straight down it, naming the service the caller asked for", func() {
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ seen := serveOneStream(worker)
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ result := dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+
+ var req servedRequest
+ Eventually(seen, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ Expect(req.tag).To(Equal(cluster.StreamTagGRPC))
+ // The TARGET is what tells the worker which backend process the
+ // stream is for. A dialer that dropped it would send every request
+ // to whichever port the worker guessed.
+ Expect(req.target).To(Equal("127.0.0.1:41000"))
+
+ var out dialResult
+ Eventually(result, "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = out.conn.Close() })
+
+ // Bytes, not a handle. A dial that returns a stream the tunnelled
+ // protocol cannot use is worse than one that fails.
+ _, err = out.conn.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ echoed := make([]byte, 4)
+ Eventually(readInto(out.conn, echoed), "10s").Should(Receive(BeNil()))
+ Expect(string(echoed)).To(Equal("ping"))
+ })
+
+ It("leaves no deadline armed on the stream it hands back", func() {
+ // The handshake is bounded; the request that follows it is the
+ // caller's business and may be a generation that is quiet for
+ // minutes. A deadline left armed here would abort it, and in
+ // production the dial context is the model-load or request budget,
+ // so the stream would die tens of seconds in.
+ //
+ // The first version of this spec did not assert that. It set a
+ // 300ms context and then wrote immediately, so the armed deadline
+ // had not expired and deleting the clear left it green: it detected
+ // only a deadline set in the PAST. What makes it bite is waiting for
+ // the dial context to actually expire FIRST, on its own Done channel
+ // rather than a sleep, and only then using the stream.
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ seen := serveOneStream(worker)
+
+ deadlined, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
+ defer cancel()
+ d := cluster.NewWorkerDialer(mine, nil)
+ result := dialAsync(d, deadlined, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+ Eventually(seen, "10s").Should(Receive())
+
+ var out dialResult
+ Eventually(result, "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = out.conn.Close() })
+
+ // The one wait this spec cannot replace with an event of its own:
+ // there is nothing to observe until the dial's deadline is behind
+ // us, and the deadline is the thing under test.
+ <-deadlined.Done()
+ Expect(deadlined.Err()).To(HaveOccurred())
+
+ // Both directions, because SetDeadline arms read and write and a
+ // clear that only covered one would still kill a live request.
+ _, err = out.conn.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ echoed := make([]byte, 4)
+ Eventually(readInto(out.conn, echoed), "10s").Should(Receive(BeNil()))
+ Expect(string(echoed)).To(Equal("ping"))
+ })
+
+ It("reports a worker's refusal as the worker's refusal, never as absence", func() {
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ refuseOneStream(worker, cluster.ErrStreamTagUnknown)
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", "nonsense", ""), "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(cluster.ErrStreamTagUnknown))
+ // A refusal is PROOF the worker is connected and answered, so it is
+ // the ONE failure on this path that carries no umbrella: it is real
+ // evidence about the worker, and folding it into "no route" would
+ // throw that evidence away.
+ expectNotAbsence(out.err)
+ Expect(out.err).ToNot(MatchError(cluster.ErrNoRoute))
+ })
+
+ It("blames the caller's own spent budget, not the worker, when the handshake ends on the deadline", func() {
+ // The third and last site of peerlink.go's callerRanOut rule.
+ //
+ // The handshake deadline IS the caller's whenever the caller's is
+ // shorter (handshakeDeadline), so a caller that has run out makes
+ // the stream's own timer fire, and the i/o timeout arrives here
+ // while ctx.Err() may still read nil. Without the guard this reads
+ // as "the tunnel would not carry the request" for a worker that is
+ // connected, healthy, and simply not answering yet, which is what a
+ // worker under load looks like.
+ //
+ // The worker accepts the stream and says nothing, so the ONLY thing
+ // that can end this handshake is the deadline; there is no sleep
+ // and no race.
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ go func() {
+ defer GinkgoRecover()
+ _, _ = worker.AcceptStream()
+ }()
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ var out dialResult
+ Eventually(dialAsync(d, deadlinePassed{ctx}, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(context.DeadlineExceeded),
+ "the caller's budget was spent; the worker never got a verdict")
+ Expect(out.err.Error()).To(ContainSubstring("the caller's own budget ran out"))
+ // The umbrella is still on it, so Dial's contract is unchanged and
+ // no consumer reads this as the worker having gone away.
+ expectNoRoute(out.err)
+ })
+
+ It("blames the caller's spent budget when it runs out BETWEEN the request and the reply", func() {
+ // R2: the read-site guard, which its sibling above cannot reach.
+ //
+ // deadlinePassed makes the budget already spent when handshake
+ // starts, so the WRITE is what fails and only the write-site guard
+ // fires. The guard that matters in production is the other one: a
+ // caller with a real budget writes its request successfully, the
+ // worker takes longer than the remainder to answer, and the READ
+ // ends on the deadline handshakeDeadline armed from that same
+ // budget. Deleting the read-site guard left the whole cluster suite
+ // green, which is exactly the "pinned at one of three sites" gap
+ // this branch has now closed twice.
+ //
+ // Deterministic without a sleep: the worker reads the request and
+ // then never answers, so nothing but the caller's own deadline can
+ // end the exchange, and the spec waits on the request having been
+ // SEEN before waiting on the dial. Seeing it is also what proves
+ // the write succeeded and therefore that this is the read site.
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ seen := make(chan servedRequest, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ seen <- servedRequest{err: err}
+ return
+ }
+ tag, target, err := cluster.ReadStreamRequest(stream)
+ seen <- servedRequest{tag: tag, target: target, stream: stream, err: err}
+ // Deliberately no reply. The caller's deadline is the only
+ // thing left that can end this handshake.
+ }()
+
+ budgeted, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
+ defer cancel()
+ d := cluster.NewWorkerDialer(mine, nil)
+ result := dialAsync(d, budgeted, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+
+ var req servedRequest
+ Eventually(seen, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred(),
+ "the request frame must have been written and read, or this spec is testing the write site")
+
+ var out dialResult
+ Eventually(result, "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(context.DeadlineExceeded),
+ "the caller ran out waiting for a reply; the worker never gave a verdict")
+ Expect(out.err.Error()).To(ContainSubstring("the caller's own budget ran out"))
+ Expect(out.err.Error()).To(ContainSubstring(`opening "grpc"`),
+ "this is the READ site; the write site says \"asking for\"")
+ expectNoRoute(out.err)
+ })
+
+ It("reports a broken tunnel held here as itself, not as a routing fact", func() {
+ // ErrNotOwner tells a caller to look for the worker elsewhere. For
+ // a tunnel held right here that sends it back to this replica, and
+ // the loop is only broken by the request failing anyway.
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(worker.Close()).To(Succeed())
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(MatchError(cluster.ErrNotOwner))
+ expectNoRoute(out.err)
+ })
+ })
+
+ Describe("when another replica holds the tunnel", func() {
+ It("relays through the owner and carries bytes to the worker", func() {
+ peers, worker := ownerRelay("w1")
+ seen := serveOneStream(worker)
+
+ d := cluster.NewWorkerDialer(mine, peers)
+ result := dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+
+ var req servedRequest
+ Eventually(seen, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ // The relay consumed its own frame and forwarded nothing of it, so
+ // the worker sees only the tunnel's request.
+ Expect(req.tag).To(Equal(cluster.StreamTagGRPC))
+ Expect(req.target).To(Equal("127.0.0.1:41000"))
+
+ var out dialResult
+ Eventually(result, "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = out.conn.Close() })
+
+ _, err := out.conn.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ echoed := make([]byte, 4)
+ Eventually(readInto(out.conn, echoed), "10s").Should(Receive(BeNil()))
+ Expect(string(echoed)).To(Equal("ping"))
+ })
+
+ It("states the caller's remaining budget in the relay request", func() {
+ // The owning replica's own open bound is a backstop nobody can set
+ // correctly: the number that matters is how long the ORIGINAL
+ // client will wait, and this replica is the only one that holds it.
+ // This spec plays the owner by hand so it can read the frame rather
+ // than infer it from a timing.
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+ dialling, ownerSide := yamuxPair()
+ DeferCleanup(func() { _ = ownerSide.Close() })
+
+ requests := make(chan relayRequest, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := ownerSide.AcceptStream()
+ if err != nil {
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ nodeID, budget, err := cluster.ReadRelayRequest(stream)
+ requests <- relayRequest{nodeID: nodeID, budget: budget, err: err}
+ }()
+
+ budgeted, cancel := context.WithTimeout(ctx, 4*time.Second)
+ defer cancel()
+ d := cluster.NewWorkerDialer(mine, &stubPeers{sess: dialling})
+ dialAsync(d, budgeted, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+
+ var req relayRequest
+ Eventually(requests, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ Expect(req.nodeID).To(Equal("w1"))
+ // Whatever is left of the four seconds, and nothing invented: a
+ // dialer that stated its own constant would satisfy neither bound.
+ Expect(req.budget).To(BeNumerically(">", 2*time.Second))
+ Expect(req.budget).To(BeNumerically("<=", 4*time.Second))
+ })
+
+ It("states no budget at all for a caller that set no deadline", func() {
+ // Zero on the wire would be read by the owner as a caller with
+ // nothing left, and it would refuse traffic that is perfectly
+ // healthy. "Not stated" has to stay distinguishable from "expired".
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+ dialling, ownerSide := yamuxPair()
+ DeferCleanup(func() { _ = ownerSide.Close() })
+
+ requests := make(chan relayRequest, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := ownerSide.AcceptStream()
+ if err != nil {
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ nodeID, budget, err := cluster.ReadRelayRequest(stream)
+ requests <- relayRequest{nodeID: nodeID, budget: budget, err: err}
+ }()
+
+ d := cluster.NewWorkerDialer(mine, &stubPeers{sess: dialling})
+ dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000")
+
+ var req relayRequest
+ Eventually(requests, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ Expect(req.budget).To(BeZero())
+ })
+
+ It("reports an unreachable peer as unreachable, NEVER as absence", func() {
+ // The catastrophe this phase exists to prevent. A scheduler ACTS on
+ // absence: told a connected worker is gone, it reclaims every model
+ // the worker is running.
+ Expect(reg.Register(ctx, "owner", "127.0.0.1:1", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+
+ pool := cluster.NewPeerPool("me", "tok", reg)
+ DeferCleanup(pool.Close)
+ d := cluster.NewWorkerDialer(mine, pool)
+
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "20s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(cluster.ErrPeerUnreachable))
+ expectNoRoute(out.err)
+ })
+
+ It("passes a stale ownership refusal back as the routing fact", func() {
+ // The owner's table row survives a tunnel that has gone. The relay
+ // answers ErrNotOwner, and only that answer tells this replica to
+ // resolve the owner again rather than give up on the worker.
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+ ownerTunnels := cluster.NewTunnelRegistry(reg, "owner")
+ store := cluster.NewSessionStore(cluster.NewRelay(ownerTunnels).Stream)
+ DeferCleanup(store.CloseAll)
+ dialling, accepted := yamuxPair()
+ store.Accept("me", accepted)
+
+ d := cluster.NewWorkerDialer(mine, &stubPeers{sess: dialling})
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(cluster.ErrNotOwner))
+ expectNoRoute(out.err)
+ })
+
+ It("refuses rather than relaying to itself when the table names this replica", func() {
+ // The row says this replica owns the tunnel and the registry says
+ // it does not hold it. Relaying would send the request to this same
+ // process, which would resolve the same owner and relay again.
+ _, err := reg.Claim(ctx, "w1", "me")
+ Expect(err).ToNot(HaveOccurred())
+
+ d := cluster.NewWorkerDialer(mine, &stubPeers{err: errors.New("no peer should be dialled")})
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(cluster.ErrNotOwner))
+ expectNoRoute(out.err)
+ })
+
+ It("reports having no way to relay as its own condition", func() {
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ Expect(out.err).To(MatchError(cluster.ErrNoRelayPath))
+ Expect(out.err).ToNot(MatchError(cluster.ErrNotOwner))
+ Expect(out.err).ToNot(MatchError(cluster.ErrPeerUnreachable))
+ expectNoRoute(out.err)
+ })
+ })
+
+ Describe("when no live replica holds the tunnel", func() {
+ // The rolling-upgrade case, and the one this phase must not get wrong.
+ //
+ // A worker's PRESENCE is its heartbeat, which lives in
+ // core/services/nodes. "No live replica holds this worker's tunnel" is
+ // a fact about tunnels and says nothing about the worker: a worker that
+ // has not dialled in yet after a frontend-first upgrade produces it on
+ // every request while it sits there heartbeating and serving models.
+ // A consumer told that is absence reclaims every one of those models.
+ It("answers no-route, never absence, for a worker with no connection row", func() {
+ peers := &stubPeers{err: errors.New("no peer should be dialled")}
+ d := cluster.NewWorkerDialer(mine, peers)
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ expectNoRoute(out.err)
+ // The cause still reaches a human.
+ Expect(fmt.Sprint(out.err)).To(ContainSubstring("no connection recorded"))
+ Expect(peers.peersDialled()).To(BeEmpty())
+ })
+
+ It("answers no-route, never absence, when the row's owner has stopped heartbeating", func() {
+ // End to end over the join Owner does: the row is there, the owner
+ // is not. The join is what stops this replica dialling a process
+ // that is gone, which is why the spec asserts no peer was dialled
+ // as well as what came back.
+ Expect(reg.Register(ctx, "ghost", "10.0.0.9:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "ghost")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(db.Exec(
+ `UPDATE instances SET last_seen = now() - make_interval(secs => ?) WHERE id = ?`,
+ cluster.InstanceLiveness.Seconds()*4, "ghost").Error).To(Succeed())
+
+ owner, _, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("ghost"))
+
+ peers := &stubPeers{err: errors.New("no peer should be dialled")}
+ d := cluster.NewWorkerDialer(mine, peers)
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ expectNoRoute(out.err)
+ Expect(peers.peersDialled()).To(BeEmpty())
+ })
+
+ It("keeps an owner swept mid-dial out of the chain as well", func() {
+ // The other absence sentinel. PeerPool resolves the owner's address
+ // through the registry, so a replica reaped between Owner and the
+ // dial comes back as ErrInstanceNotFound. That is absence of a
+ // REPLICA, and a consumer matching absence would read it as absence
+ // of the WORKER.
+ Expect(reg.Register(ctx, "owner", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "owner")
+ Expect(err).ToNot(HaveOccurred())
+
+ d := cluster.NewWorkerDialer(mine, &stubPeers{err: cluster.ErrInstanceNotFound})
+ var out dialResult
+ Eventually(dialAsync(d, ctx, "w1", cluster.StreamTagGRPC, "127.0.0.1:41000"), "10s").Should(Receive(&out))
+ expectNoRoute(out.err)
+ })
+ })
+
+ Describe("the dialer functions it hands to the transports", func() {
+ It("binds one node and one tag, and passes the address through as the target", func() {
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ seen := serveOneStream(worker)
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ dial := d.DialerFor("w1", cluster.StreamTagHTTP)
+ done := make(chan dialResult, 1)
+ go func() {
+ defer GinkgoRecover()
+ conn, err := dial(ctx, "tcp", "10.0.0.3:9090")
+ done <- dialResult{conn: conn, err: err}
+ }()
+
+ var req servedRequest
+ Eventually(seen, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ Expect(req.tag).To(Equal(cluster.StreamTagHTTP))
+ Expect(req.target).To(Equal("10.0.0.3:9090"))
+
+ var out dialResult
+ Eventually(done, "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = out.conn.Close() })
+ })
+
+ It("gives gRPC a dialer fixed on the grpc tag", func() {
+ frontend, worker := workerTunnel()
+ _, err := mine.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ seen := serveOneStream(worker)
+
+ d := cluster.NewWorkerDialer(mine, nil)
+ dial := d.GRPCDialerFor("w1")
+ done := make(chan dialResult, 1)
+ go func() {
+ defer GinkgoRecover()
+ conn, err := dial(ctx, "127.0.0.1:41000")
+ done <- dialResult{conn: conn, err: err}
+ }()
+
+ var req servedRequest
+ Eventually(seen, "10s").Should(Receive(&req))
+ Expect(req.err).ToNot(HaveOccurred())
+ Expect(req.tag).To(Equal(cluster.StreamTagGRPC))
+ Expect(req.target).To(Equal("127.0.0.1:41000"))
+
+ var out dialResult
+ Eventually(done, "10s").Should(Receive(&out))
+ Expect(out.err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = out.conn.Close() })
+ })
+ })
+})
+
+var _ = Describe("The relay request frame", func() {
+ It("carries a stated budget and reads it back", func() {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteRelayRequest(frame, "node-7", 2500*time.Millisecond)).To(Succeed())
+ nodeID, budget, err := cluster.ReadRelayRequest(frame)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(nodeID).To(Equal("node-7"))
+ Expect(budget).To(Equal(2500 * time.Millisecond))
+ })
+
+ It("writes no budget at all when none is stated", func() {
+ // Zero must not reach the wire as the number zero: on the far side that
+ // is a caller with no time left, and the relay would refuse healthy
+ // traffic instead of falling back to its ceiling.
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteRelayRequest(frame, "node-7", 0)).To(Succeed())
+ Expect(frame.Len()).To(Equal(2 + len("node-7")))
+ nodeID, budget, err := cluster.ReadRelayRequest(frame)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(nodeID).To(Equal("node-7"))
+ Expect(budget).To(BeZero())
+ })
+
+ It("refuses a node id that would split across the separator", func() {
+ Expect(cluster.WriteRelayRequest(&bytes.Buffer{}, "node 7", time.Second)).ToNot(Succeed())
+ Expect(cluster.WriteRelayRequest(&bytes.Buffer{}, "", time.Second)).ToNot(Succeed())
+ })
+
+ It("rejects a budget that is not a number of milliseconds", func() {
+ // The writer cannot produce this; a mismatched peer can, and reading it
+ // as "not stated" would silently restore the ceiling this frame exists
+ // to replace.
+ var raw bytes.Buffer
+ writeRawFrame(&raw, "node-7 soon")
+ _, _, err := cluster.ReadRelayRequest(&raw)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("treats an expired stated budget as an error rather than as silence", func() {
+ var raw bytes.Buffer
+ writeRawFrame(&raw, "node-7 0")
+ _, _, err := cluster.ReadRelayRequest(&raw)
+ Expect(err).To(HaveOccurred())
+ Expect(fmt.Sprint(err)).To(ContainSubstring("expired"))
+ })
+})
diff --git a/core/services/cluster/instance.go b/core/services/cluster/instance.go
new file mode 100644
index 000000000000..606337a381ff
--- /dev/null
+++ b/core/services/cluster/instance.go
@@ -0,0 +1,312 @@
+// Package cluster records the frontend replicas that make up one LocalAI
+// deployment and, later, the links between them. It is deliberately free of
+// dependencies on core/services/nodes: nodes migrates and consumes the models
+// declared here, so an import in the other direction would be a cycle.
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// ErrInstanceNotFound reports that no row exists for the requested instance ID.
+// Callers distinguish it from a transport failure to decide whether to
+// re-register or to retry.
+var ErrInstanceNotFound = errors.New("cluster: instance not found")
+
+// Instance is one live frontend replica, keyed by the ID that replica chose for
+// itself. Column sizes mirror nodes.BackendNode so both tables agree on what an
+// ID and a host:port look like.
+type Instance struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ AdvertisedAddr string `gorm:"size:255" json:"advertised_addr"` // host:port other replicas dial
+ Version string `gorm:"size:64" json:"version"`
+ LastSeen time.Time `gorm:"index" json:"last_seen"`
+}
+
+// Registry reads and writes the instances table.
+type Registry struct {
+ db *gorm.DB
+}
+
+// NewRegistry returns a Registry over db. Migration is the caller's job: this
+// package's tables and sequence are created by Migrate, which the nodes
+// registry calls under the one advisory lock that covers every table in the
+// deployment.
+func NewRegistry(db *gorm.DB) *Registry {
+ return &Registry{db: db}
+}
+
+// Register records this replica's address, refreshing LastSeen. It upserts on
+// the primary key rather than deleting and re-inserting, so a concurrent Live
+// never observes a live replica as missing.
+func (r *Registry) Register(ctx context.Context, id, addr, version string) error {
+ // last_seen is stamped by the database, never by this process. Liveness is
+ // compared across replicas, so it has to be measured on the one clock they
+ // all share; with per-replica clocks the effective Live window becomes
+ // `within - writerBehind - readerAhead`, which either evicts healthy peers
+ // or keeps dead ones alive.
+ if err := r.db.WithContext(ctx).Model(&Instance{}).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "id"}},
+ DoUpdates: clause.Assignments(map[string]any{
+ "advertised_addr": addr,
+ "version": version,
+ "last_seen": gorm.Expr("now()"),
+ }),
+ }).Create(map[string]any{
+ "id": id,
+ "advertised_addr": addr,
+ "version": version,
+ "last_seen": gorm.Expr("now()"),
+ }).Error; err != nil {
+ return fmt.Errorf("registering instance %q: %w", id, err)
+ }
+ return nil
+}
+
+// Heartbeat refreshes LastSeen for an already-registered instance. An unknown
+// ID is an error rather than an insert: a heartbeat carries no address, so
+// inserting would publish a replica nobody can reach.
+func (r *Registry) Heartbeat(ctx context.Context, id string) error {
+ // gorm reports no error when a Where matches nothing, so the miss has to be
+ // read off RowsAffected.
+ res := r.db.WithContext(ctx).Model(&Instance{}).
+ Where("id = ?", id).
+ Update("last_seen", gorm.Expr("now()"))
+ if res.Error != nil {
+ return fmt.Errorf("heartbeating instance %q: %w", id, res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return fmt.Errorf("heartbeating instance %q: %w", id, ErrInstanceNotFound)
+ }
+ return nil
+}
+
+// instanceIsLive is the one predicate that decides whether a replica is still
+// alive, and it takes the window in seconds as its single bind parameter. Every
+// reader of that fact is written in terms of it: Live lists the rows it selects,
+// Owner refuses an owner it rejects, and ReapStale deletes its negation. Two
+// spellings of one fact drift, and the drift would show up as a relay to a
+// replica one query calls dead and another calls alive.
+//
+// The column is table-qualified because Owner reads it across a join, where an
+// unqualified last_seen would be ambiguous. Postgres folds the unquoted name to
+// the same table gorm quotes, so the qualification costs Live nothing.
+//
+// The cutoff is computed by the database for the same reason Register stamps
+// there: liveness is compared across replicas, so a reader's own clock must not
+// decide whether another replica is alive.
+const instanceIsLive = `instances.last_seen > now() - make_interval(secs => ?)`
+
+// Live returns the instances whose LastSeen is newer than now-within.
+func (r *Registry) Live(ctx context.Context, within time.Duration) ([]Instance, error) {
+ var out []Instance
+ if err := r.db.WithContext(ctx).
+ Where(instanceIsLive, within.Seconds()).
+ Order("id").
+ Find(&out).Error; err != nil {
+ return nil, fmt.Errorf("listing live instances: %w", err)
+ }
+ return out, nil
+}
+
+// Get returns one instance, or ErrInstanceNotFound if it is not registered.
+func (r *Registry) Get(ctx context.Context, id string) (*Instance, error) {
+ var inst Instance
+ err := r.db.WithContext(ctx).Where("id = ?", id).First(&inst).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("getting instance %q: %w", id, ErrInstanceNotFound)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("getting instance %q: %w", id, err)
+ }
+ return &inst, nil
+}
+
+// DiscoverAdvertisedAddr determines the address this replica should advertise
+// to its peers, with no operator configuration.
+//
+// Every replica in a deployment reaches the same PostgreSQL server, so the
+// local interface that routes to PostgreSQL is on a network all the replicas
+// demonstrably share. Opening a UDP socket toward the database sends no packet;
+// it only asks the kernel to pick a source address for that route, which is the
+// address to advertise. The caller supplies the port, since the frontend's
+// listening port has nothing to do with the database's.
+//
+// What defeats the discovery is a DSN that NAMES loopback, not the database
+// being co-located. Co-location is fine as long as the DSN names something
+// routable: compose's usual `host=postgres` resolves to a bridge address, so
+// the kernel picks this container's own bridge IP as the source, which is the
+// address a peer on that network dials. It is `host=localhost` (or 127.0.0.1,
+// or ::1) that makes the route loopback, and advertising 127.0.0.1 would make
+// a peer dialling this replica reach itself instead. So an unspecified,
+// loopback, or scoped source address is rejected with an error telling the
+// operator to configure the advertised address explicitly, rather than
+// returned. There is no fallback string: no address is better than a wrong one.
+func DiscoverAdvertisedAddr(dsn string, port int) (string, error) {
+ // A port of 0 (or out of range) would produce an address nothing can dial,
+ // and the caller is likelier to have passed an unset field than to mean it.
+ if port < 1 || port > 65535 {
+ return "", fmt.Errorf("advertised port %d is out of range 1-65535", port)
+ }
+ host, dbPort, err := dsnHostPort(dsn)
+ if err != nil {
+ return "", err
+ }
+ conn, err := net.Dial("udp", net.JoinHostPort(host, dbPort))
+ if err != nil {
+ return "", fmt.Errorf("resolving route to database host %q: %w", host, err)
+ }
+ // Nothing was ever sent on this socket, so a close failure carries no
+ // information about the address we just read.
+ defer func() { _ = conn.Close() }()
+ local, ok := conn.LocalAddr().(*net.UDPAddr)
+ if !ok || local.IP == nil {
+ return "", fmt.Errorf("no local address on the route to database host %q; set the advertised address explicitly", host)
+ }
+ if reason := unroutableReason(local.IP, local.Zone); reason != "" {
+ return "", fmt.Errorf("the route to database host %q is %s; set the advertised address explicitly", host, reason)
+ }
+ return net.JoinHostPort(local.IP.String(), strconv.Itoa(port)), nil
+}
+
+// unroutableReason says why ip cannot serve as an address other hosts dial, or
+// "" when it can. It is the one place that decides, so the discovered address
+// and the configured one are held to the same rule; they differ only in what
+// they do with the answer.
+func unroutableReason(ip net.IP, zone string) string {
+ switch {
+ case ip == nil || ip.IsUnspecified():
+ return fmt.Sprintf("unspecified (%s), which is a bind address rather than one anything can connect to", ip)
+ case ip.IsLoopback():
+ return fmt.Sprintf("loopback (%s), which means \"this host\" to whoever dials it, so every peer would reach itself", ip)
+ case ip.IsLinkLocalUnicast():
+ return fmt.Sprintf("link-local (%s), which peers on other hosts cannot dial", withZone(ip, zone))
+ // A zone is normally attached only to a link-local address, which the case
+ // above already rejects. This one stays for the scoped address of some
+ // other class a platform may hand back, and says so rather than repeating
+ // the link-local label: the two have different cures, and an operator told
+ // the wrong one looks in the wrong place.
+ case zone != "":
+ return fmt.Sprintf("scoped to interface %q (%s), and the zone is dropped by the time an address is stored, leaving a host nothing can dial", zone, withZone(ip, zone))
+ }
+ return ""
+}
+
+// withZone renders the address the way it has to be dialled. IP.String() drops
+// the %iface, so an unadorned %s in a rejection reports an address that differs
+// from the one being rejected.
+func withZone(ip net.IP, zone string) string {
+ if zone == "" {
+ return ip.String()
+ }
+ return ip.String() + "%" + zone
+}
+
+// CheckAdvertisedAddr validates an address an operator configured, returning a
+// reason it is questionable, or an error if it is unusable.
+//
+// A configured address bypasses every check DiscoverAdvertisedAddr performs,
+// and the value most likely to be copied is the one that works on a single
+// host: "127.0.0.1:8080" on three hosts makes every peer dial itself, which
+// presents as a relay loop rather than as a configuration error.
+//
+// The split between error and reason is deliberate. An address that cannot be
+// parsed into host and port is an error, because nothing can dial it at all. An
+// address that merely means "this host" is a reason to warn and no more: a
+// single-host deployment, including this repository's own e2e cluster, uses one
+// correctly, and refusing it would be refusing a supported topology.
+func CheckAdvertisedAddr(addr string) (reason string, err error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return "", fmt.Errorf("advertised address %q is not host:port: %w", addr, err)
+ }
+ if host == "" {
+ return "", fmt.Errorf("advertised address %q names no host, so peers have nothing to dial", addr)
+ }
+ portNumber, err := strconv.Atoi(port)
+ if err != nil || portNumber < 1 || portNumber > 65535 {
+ return "", fmt.Errorf("advertised address %q has no usable port (want 1-65535)", addr)
+ }
+ // The zone is split off before parsing because net.ParseIP rejects
+ // "fe80::1%eth0" outright. Left joined, a scoped literal would look like a
+ // name and collect no warning at all, which is the one case where the
+ // address is guaranteed not to work for a peer.
+ host, zone := splitZone(host)
+ // A name is resolved by whoever dials it, and may resolve differently
+ // there, so its presence is all this side can check.
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return "", nil
+ }
+ return unroutableReason(ip, zone), nil
+}
+
+// splitZone separates an IPv6 scope from the address it qualifies. A name
+// never carries one, so a host with no "%" comes back unchanged.
+func splitZone(host string) (string, string) {
+ addr, zone, found := strings.Cut(host, "%")
+ if !found {
+ return host, ""
+ }
+ return addr, zone
+}
+
+// dsnHostPort extracts the host and port from either DSN form gorm's postgres
+// driver accepts: a URL ("postgres://user:pass@host:5432/db") or libpq keyword
+// pairs ("host=... port=...").
+func dsnHostPort(dsn string) (string, string, error) {
+ const defaultPort = "5432"
+ dsn = strings.TrimSpace(dsn)
+ if dsn == "" {
+ return "", "", errors.New("empty database DSN")
+ }
+
+ if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
+ u, err := url.Parse(dsn)
+ if err != nil {
+ return "", "", fmt.Errorf("parsing database DSN: %w", err)
+ }
+ host := u.Hostname()
+ if host == "" {
+ return "", "", errors.New("database DSN has no host")
+ }
+ port := u.Port()
+ if port == "" {
+ port = defaultPort
+ }
+ return host, port, nil
+ }
+
+ host, port := "", defaultPort
+ for _, field := range strings.Fields(dsn) {
+ key, value, found := strings.Cut(field, "=")
+ if !found {
+ continue
+ }
+ switch key {
+ case "host":
+ host = value
+ case "port":
+ port = value
+ }
+ }
+ if host == "" {
+ return "", "", errors.New("database DSN has no host")
+ }
+ // A Unix socket directory tells us nothing about which interface reaches
+ // the database, so there is no address to derive.
+ if strings.HasPrefix(host, "/") {
+ return "", "", fmt.Errorf("database DSN uses a unix socket (%q); no routable address to advertise", host)
+ }
+ return host, port, nil
+}
diff --git a/core/services/cluster/instance_test.go b/core/services/cluster/instance_test.go
new file mode 100644
index 000000000000..aa0349496161
--- /dev/null
+++ b/core/services/cluster/instance_test.go
@@ -0,0 +1,173 @@
+package cluster_test
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+var _ = Describe("Instance registry", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ })
+
+ It("registers an instance and reads it back", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+
+ got, err := reg.Get(ctx, "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.AdvertisedAddr).To(Equal("10.0.0.1:8080"))
+ Expect(got.Version).To(Equal("v1"))
+ })
+
+ It("re-registering the same id updates the address instead of duplicating", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.9:9090", "v2")).To(Succeed())
+
+ live, err := reg.Live(ctx, time.Hour)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(HaveLen(1))
+ Expect(live[0].AdvertisedAddr).To(Equal("10.0.0.9:9090"))
+ })
+
+ It("reports a missing instance distinguishably", func() {
+ _, err := reg.Get(ctx, "nope")
+ Expect(err).To(MatchError(cluster.ErrInstanceNotFound))
+ })
+
+ It("excludes instances whose heartbeat has aged out", func() {
+ Expect(reg.Register(ctx, "stale", "10.0.0.1:8080", "v1")).To(Succeed())
+ // Age the row directly; sleeping in a spec is forbidden.
+ Expect(db.Model(&cluster.Instance{}).Where("id = ?", "stale").
+ Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
+
+ live, err := reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(BeEmpty())
+ })
+
+ It("brings a stale instance back with a heartbeat", func() {
+ Expect(reg.Register(ctx, "revive", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(db.Model(&cluster.Instance{}).Where("id = ?", "revive").
+ Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
+ Expect(reg.Heartbeat(ctx, "revive")).To(Succeed())
+
+ live, err := reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(HaveLen(1))
+ })
+
+ It("heartbeating an unknown instance is an error, not a silent insert", func() {
+ Expect(reg.Heartbeat(ctx, "ghost")).To(MatchError(cluster.ErrInstanceNotFound))
+ })
+})
+
+var _ = Describe("Advertised address discovery", func() {
+ // The address itself depends on host networking and is deliberately not
+ // asserted. What is portable is the shape: whatever interface routes to the
+ // database, the port must be the one the caller asked for, not the
+ // database's.
+ It("combines a local interface with the caller's port", func() {
+ addr, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 8080)
+ if err != nil {
+ Skip("no route to a database host on this machine: " + err.Error())
+ }
+ host, port, splitErr := net.SplitHostPort(addr)
+ Expect(splitErr).ToNot(HaveOccurred())
+ Expect(port).To(Equal("8080"))
+ Expect(net.ParseIP(host)).ToNot(BeNil())
+ })
+
+ It("refuses a DSN it cannot derive an address from", func() {
+ _, err := cluster.DiscoverAdvertisedAddr("", 8080)
+ Expect(err).To(HaveOccurred())
+ })
+
+ // A DSN that NAMES loopback routes over loopback on every platform, so this
+ // is deterministic rather than host-dependent. Co-location is not the
+ // trigger: compose's `host=postgres` resolves to a bridge address and
+ // discovery works there. Returning 127.0.0.1 would make a peer dialling
+ // this replica reach itself.
+ It("refuses a loopback route instead of advertising an address peers cannot use", func() {
+ addr, err := cluster.DiscoverAdvertisedAddr("postgres://user@127.0.0.1:5432/testdb", 8080)
+ Expect(addr).To(BeEmpty())
+ Expect(err).To(MatchError(ContainSubstring("loopback")))
+ })
+
+ It("refuses a port that cannot be dialled", func() {
+ _, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 0)
+ Expect(err).To(MatchError(ContainSubstring("out of range")))
+ })
+})
+
+var _ = Describe("Checking a configured advertised address", func() {
+ // The configured address bypasses discovery entirely, so it bypasses every
+ // rejection discovery makes. These are the checks that put back the ones
+ // that can be made without a route to look at.
+ It("accepts an address on a network other hosts can reach", func() {
+ reason, err := cluster.CheckAdvertisedAddr("10.0.0.7:8080")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reason).To(BeEmpty())
+ })
+
+ It("accepts a name, because the dialler is what resolves it", func() {
+ reason, err := cluster.CheckAdvertisedAddr("localai-frontend.default.svc:8080")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reason).To(BeEmpty())
+ })
+
+ It("refuses an address with no port, which nothing could dial", func() {
+ _, err := cluster.CheckAdvertisedAddr("10.0.0.7")
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("refuses a port outside the dialable range", func() {
+ _, err := cluster.CheckAdvertisedAddr("10.0.0.7:0")
+ Expect(err).To(MatchError(ContainSubstring("port")))
+ })
+
+ It("refuses an address that names no host", func() {
+ _, err := cluster.CheckAdvertisedAddr(":8080")
+ Expect(err).To(MatchError(ContainSubstring("no host")))
+ })
+
+ It("reports loopback without refusing it, because one host is a supported topology", func() {
+ // Correct on a single host, and the value most likely to be copied
+ // onto three, where every peer would then dial itself.
+ reason, err := cluster.CheckAdvertisedAddr("127.0.0.1:8080")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reason).To(ContainSubstring("loopback"))
+ })
+
+ It("reports a bind address, which is not an address at all", func() {
+ reason, err := cluster.CheckAdvertisedAddr("0.0.0.0:8080")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reason).To(ContainSubstring("unspecified"))
+ })
+
+ It("reports a scoped literal, which net.ParseIP alone would wave through as a name", func() {
+ // The zone has to be split off before parsing, or this address is
+ // indistinguishable from a hostname and collects no warning at all.
+ reason, err := cluster.CheckAdvertisedAddr("[fe80::1%eth0]:8080")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reason).ToNot(BeEmpty(), "a scoped address peers cannot dial was accepted in silence")
+ Expect(reason).To(ContainSubstring("fe80::1%eth0"),
+ "the reported address must carry its zone, or it is not the address being rejected")
+ })
+})
diff --git a/core/services/cluster/membership.go b/core/services/cluster/membership.go
new file mode 100644
index 000000000000..2dad1de6ae60
--- /dev/null
+++ b/core/services/cluster/membership.go
@@ -0,0 +1,444 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+
+ "github.com/mudler/xlog"
+ "gorm.io/gorm"
+)
+
+const (
+ // InstanceHeartbeat is how often a replica refreshes its own row.
+ InstanceHeartbeat = 5 * time.Second
+
+ // InstanceLiveness is how long a replica may go without a heartbeat before
+ // its peers treat it as gone: six consecutive misses.
+ //
+ // The window is generous on purpose. Declaring a replica dead records a
+ // departure for every connection row it owned, and a worker recorded as
+ // departed while its owner is merely slow has to be re-homed for nothing.
+ // The cost of waiting is bounded and symmetric: traffic for that worker is
+ // retried, not lost.
+ InstanceLiveness = 30 * time.Second
+
+ // deregisterTimeout bounds the deregistration Stop performs. Shutdown is
+ // not the place to wait on a database.
+ deregisterTimeout = 5 * time.Second
+
+ // DepartedRetention is the FLOOR on how long a connection row outlives the
+ // tunnel it recorded before the sweep deletes it. DepartedRetentionFor is
+ // what the sweep actually applies; this is what it never goes below.
+ //
+ // A multiple of the liveness window rather than the window itself. The row
+ // survives so that how long ago a tunnel went can be answered at all, and a
+ // retention as short as the window a reader compares that age against would
+ // let the purge delete the row out from under the reader, turning a worker
+ // that is re-dialling back into a worker that was never here. Ten windows
+ // is far enough clear of any such comparison, and short enough that a
+ // worker retired for good does not sit in the table for a day.
+ DepartedRetention = 10 * InstanceLiveness
+
+ // departedRetentionGraceFactor is how many reconnect graces a departure is
+ // kept for once the grace is the larger of the two. Five, so the purge
+ // window is nowhere near the window a reader measures against, the same
+ // margin DepartedRetention keeps over the liveness window.
+ departedRetentionGraceFactor = 5
+)
+
+// DepartedRetentionFor returns how long a departure must be kept, given the
+// reconnect grace the readers of that departure measure against it.
+//
+// It exists because the two windows are set by different people. The retention
+// is this package's, the grace is an operator's
+// (DistributedConfig.WorkerReconnectGrace), and a fixed retention is only
+// correct while nobody raises the grace past it. An operator who does gets a
+// purge that deletes departures BEFORE the grace has elapsed, so Presence stops
+// answering PresenceGone for that worker and answers PresenceUnknown forever
+// instead. Nobody may act on unknown, so nothing is misreported; but nothing
+// ever reaps that worker's rows either, and the leak is silent.
+//
+// So the retention is derived rather than declared, and the ordering is a
+// property of this function rather than of two constants that happen to agree.
+func DepartedRetentionFor(grace time.Duration) time.Duration {
+ // Guarded before the multiply, not after. A grace above roughly 58 years
+ // overflows time.Duration's int64 nanoseconds, and the product comes back
+ // negative or small, so a plain `scaled > DepartedRetention` would fall
+ // through to the floor and reinstate the very defect this function exists
+ // to remove, silently and only for the operator who set the largest window.
+ if grace > time.Duration(math.MaxInt64)/departedRetentionGraceFactor {
+ return time.Duration(math.MaxInt64)
+ }
+ if scaled := departedRetentionGraceFactor * grace; scaled > DepartedRetention {
+ return scaled
+ }
+ return DepartedRetention
+}
+
+// Membership publishes this replica's address and keeps the instances table
+// free of replicas that have stopped answering.
+//
+// It is the only writer of this replica's row and the only sweeper of anyone
+// else's, which is what keeps one fact on one clock: whether a replica is
+// alive is answered by its last_seen and by nothing else.
+type Membership struct {
+ reg *Registry
+ id string
+ addr string
+ version string
+
+ interval time.Duration
+ liveness time.Duration
+ // retention is how long a departure is kept before the purge deletes it.
+ // It is derived from the reconnect grace rather than fixed, because the
+ // grace is the window Presence measures departures against and the purge
+ // must never outrun it (see DepartedRetentionFor).
+ retention time.Duration
+
+ stop chan struct{}
+ done chan struct{}
+ stopOnce sync.Once
+
+ // mu guards started, which tells Stop whether there is a loop to join, and
+ // tunnels, which SetTunnels may write while the loop is already reading it.
+ mu sync.Mutex
+ started bool
+ tunnels *TunnelRegistry
+}
+
+// NewMembership returns the membership loop for one replica. The address is
+// what peers will dial, so it must be reachable from another host, not the
+// address this process binds.
+func NewMembership(reg *Registry, id, addr, version string) *Membership {
+ return &Membership{
+ reg: reg,
+ id: id,
+ addr: addr,
+ version: version,
+ interval: InstanceHeartbeat,
+ liveness: InstanceLiveness,
+ retention: DepartedRetention,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+}
+
+// SetTunnels gives the loop the registry holding this replica's worker tunnels,
+// so it can re-claim them after its rows have been swept. A Membership without
+// one still heartbeats and sweeps; it simply has nothing to re-claim, which is
+// the single-binary case and the case of a replica that accepts no tunnels.
+//
+// It is a setter rather than a constructor argument because the tunnel registry
+// is what the tunnel endpoint is built on, and that is wired after membership
+// is already running.
+//
+// Safe on a nil receiver, like Stop. This package deliberately produces a nil
+// *Membership (core/application/distributed.go leaves it nil when no
+// peer-reachable address can be derived), so a setter that panicked on one
+// would be a trap for the next caller rather than an impossibility.
+func (m *Membership) SetTunnels(t *TunnelRegistry) {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.tunnels = t
+}
+
+// SetReconnectGrace tells the loop the window Presence measures a departure
+// against, so the purge keeps departures for longer than any reader needs them.
+// A Membership that is never told one purges on the DepartedRetention floor,
+// which is correct for every grace at or below it.
+//
+// It is a setter for the reason SetTunnels is: this package deliberately
+// produces a nil *Membership (core/application/distributed.go leaves it nil
+// when no peer-reachable address can be derived), so it is safe on one.
+func (m *Membership) SetReconnectGrace(grace time.Duration) {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.retention = DepartedRetentionFor(grace)
+}
+
+// Start registers this replica and begins heartbeating and sweeping. The first
+// registration is synchronous and its failure is returned: a replica whose
+// address never reaches the table is invisible to its peers, and starting
+// anyway would hide that behind a background log line.
+func (m *Membership) Start(ctx context.Context) error {
+ if err := m.reg.Register(ctx, m.id, m.addr, m.version); err != nil {
+ return err
+ }
+ xlog.Info("Cluster instance registered", "id", m.id, "addr", m.addr)
+ m.mu.Lock()
+ m.started = true
+ m.mu.Unlock()
+ go m.loop(ctx)
+ return nil
+}
+
+// Stop ends the loop, waits for it, and removes this replica's row.
+//
+// Deregistering is what makes a rolling restart quick for everyone else: a
+// replica that just closes its sockets is indistinguishable from one that
+// crashed, so its peers keep dialling it for the whole liveness window. It is
+// best-effort by nature (a killed process never gets here), which is why the
+// sweeper still exists.
+//
+// Safe to call more than once, and on a Membership that was never started.
+func (m *Membership) Stop() {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ started := m.started
+ m.mu.Unlock()
+ if started {
+ m.stopOnce.Do(func() { close(m.stop) })
+ // Only a started Membership ever closes done. Waiting on one that was
+ // never started, or whose Start failed, would block forever.
+ <-m.done
+ }
+
+ // Deliberately NOT the context Start was given: that one is the
+ // application's, and by the time anything calls Stop it has usually been
+ // cancelled already, so deregistering on it would fail every time. The
+ // bound is here instead, because shutdown must not hang on a database that
+ // went away before the process using it.
+ ctx, cancel := context.WithTimeout(context.Background(), deregisterTimeout)
+ defer cancel()
+ if err := m.reg.Deregister(ctx, m.id); err != nil {
+ xlog.Warn("Deregistering this replica failed; peers will drop it when its heartbeat ages out",
+ "id", m.id, "within", m.liveness, "error", err)
+ return
+ }
+ xlog.Info("Cluster instance deregistered", "id", m.id)
+}
+
+func (m *Membership) loop(ctx context.Context) {
+ defer close(m.done)
+
+ ticker := time.NewTicker(m.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-m.stop:
+ return
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ m.tick(ctx)
+ }
+ }
+}
+
+// tick refreshes this replica's row and sweeps the dead.
+//
+// Every replica sweeps, rather than one elected sweeper. The deletes are
+// idempotent and cheap, and an elected sweeper is one more thing that has to be
+// alive for the cluster to notice that something is not.
+func (m *Membership) tick(ctx context.Context) {
+ err := m.reg.Heartbeat(ctx, m.id)
+ if errors.Is(err, ErrInstanceNotFound) {
+ // Another replica swept this row while this process was stalled long
+ // enough to look dead. Re-register rather than heartbeat: a heartbeat
+ // carries no address, so the row has to be rebuilt from scratch.
+ //
+ // Register rebuilds the instance row ONLY. The sweep that removed it
+ // recorded a departure for every connection this replica owned, in the
+ // same transaction, so the tunnels still held here have to be claimed
+ // again or this replica serves workers that, as far as every other
+ // replica can see, are connected nowhere.
+ xlog.Warn("Cluster instance row was reaped, re-registering", "id", m.id)
+ if err := m.reg.Register(ctx, m.id, m.addr, m.version); err == nil {
+ m.reclaimTunnels(ctx)
+ } else {
+ // Re-claiming is skipped and only re-claiming: a claim written now
+ // would name an instance row that does not exist, and the very next
+ // sweep deletes it as an orphan. The sweep below still runs, because
+ // what it removes is other replicas, and this replica failing to
+ // rebuild its own row is no reason to stop reaping theirs.
+ xlog.Error("Re-registering cluster instance failed", "id", m.id, "error", err)
+ }
+ } else if err != nil {
+ xlog.Warn("Cluster instance heartbeat failed", "id", m.id, "error", err)
+ }
+
+ instances, cleared, err := m.reg.ReapStale(ctx, m.id, m.liveness)
+ if err != nil {
+ xlog.Warn("Reaping stale cluster instances failed", "error", err)
+ return
+ }
+ if instances > 0 || cleared > 0 {
+ // Two verbs because the sweep does two things: the instance rows are
+ // gone, the connection rows are still there and now record a departure.
+ xlog.Info("Swept cluster state left by dead replicas",
+ "instances_deleted", instances, "connections_departed", cleared)
+ }
+
+ // The retention has an owner, and it is this sweep. A departure that
+ // nothing ever deletes is a row per worker that ever dialled this
+ // deployment, and it is the same loop that decides a replica is gone, so
+ // there is one schedule rather than two.
+ m.mu.Lock()
+ retention := m.retention
+ m.mu.Unlock()
+ purged, err := m.reg.PurgeDepartedBefore(ctx, retention)
+ if err != nil {
+ xlog.Warn("Purging departed worker connections failed", "error", err)
+ return
+ }
+ if purged > 0 {
+ xlog.Info("Purged worker connections whose departure aged out", "connections", purged, "retention", retention)
+ }
+}
+
+// reclaimTunnels re-writes a claim for every worker tunnel this replica still
+// holds, after the sweep that recorded them as departed. It is separate from
+// tick only so the lock around the registry reference is not held across the
+// database work.
+func (m *Membership) reclaimTunnels(ctx context.Context) {
+ m.mu.Lock()
+ tunnels := m.tunnels
+ m.mu.Unlock()
+ if tunnels == nil {
+ return
+ }
+
+ reclaimed, err := tunnels.Reclaim(ctx)
+ if err != nil {
+ // Logged rather than returned, and the loop keeps running: the next
+ // heartbeat fails the same way if the row is still missing, so the
+ // re-claim is retried. A worker whose claim never lands is reachable
+ // only through the replica it is connected to, which is this one.
+ xlog.Error("Re-claiming worker tunnels after this replica was reaped failed", "id", m.id, "error", err)
+ }
+ if reclaimed > 0 {
+ xlog.Info("Re-claimed worker tunnels after this replica was reaped", "id", m.id, "tunnels", reclaimed)
+ }
+}
+
+// Deregister removes one replica and records a departure for every connection
+// it owned.
+//
+// Both in one transaction, for the same reason ReapStale does it in one: a
+// replica that is gone owns nothing, and leaving its connection rows pointing
+// at it would point every reader at an owner that no longer exists. This is the
+// announced form of what the sweeper does by inference, and the two must not
+// disagree about what "gone" leaves behind.
+//
+// The connection rows are cleared, not deleted: a worker whose frontend shut
+// down is about to re-dial the load balancer, and erasing its row would make
+// the seconds in between look like a worker that had never connected.
+//
+// Instances first, then connections, which is deliberate and is the same order
+// ReapStale takes. The two paths run concurrently in the ordinary case, a
+// replica shutting down while a peer is sweeping it, and each locks the same
+// two tables; opposite orders would let each hold the row the other is waiting
+// for. PostgreSQL breaks such a cycle by aborting one side, so the cost is a
+// failed shutdown rather than lost data, but an inversion that costs nothing to
+// remove should not be left in.
+func (r *Registry) Deregister(ctx context.Context, id string) error {
+ if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // No RowsAffected check: deregistering a row another replica already
+ // swept is the normal outcome of a slow shutdown, not an error.
+ if err := tx.Where("id = ?", id).Delete(&Instance{}).Error; err != nil {
+ return fmt.Errorf("deleting instance %q: %w", id, err)
+ }
+ // Held rows only, for the reason Owner filters rather than leaning on
+ // the join: an id column that is never empty is an accident of who
+ // registers, not a property, and an empty id here would match every
+ // departed row in the table and reset every departure's age.
+ if err := tx.Model(&NodeConnection{}).
+ Where("owner_instance_id = ? AND "+connectionIsHeld, id).
+ Updates(departure()).Error; err != nil {
+ return fmt.Errorf("recording departures for connections owned by %q: %w", id, err)
+ }
+ return nil
+ }); err != nil {
+ return fmt.Errorf("deregistering instance %q: %w", id, err)
+ }
+ return nil
+}
+
+// ReapStale deletes the replicas that have not heartbeated within the liveness
+// window, and records a departure for every connection row whose owner is no
+// longer among the survivors.
+//
+// The two are one sweeper on purpose. A connection row is only ever orphaned by
+// its owner dying, so the moment that is decided is the moment to clean up
+// after it; a second sweeper with its own schedule would either lag this one or
+// race it, and would need its own answer to "is that replica alive", which is
+// the one fact this table already owns.
+//
+// The connection rows are cleared and not deleted, which is why the second
+// return is named for what it counts: rows this sweep CLEARED, never rows it
+// removed. Reading it as a delete count would make a worker that is re-dialling
+// right now look like one this deployment has forgotten. A worker whose owning
+// replica died has not gone anywhere: it is re-dialling the load balancer, and
+// deleting its row would erase the departure that says how long ago that
+// started.
+//
+// self is never reaped. This process may fail to heartbeat for longer than the
+// window (a long stall, a database blip) and still be serving: deleting its own
+// row would then mark as departed the workers that are, at that moment,
+// connected to it.
+//
+// That protection is one-sided. A replica that stalls long enough is reaped BY
+// ANOTHER replica, which records a departure for every connection it held, and
+// Register rebuilds the instance row and nothing else. What restores the rest
+// is the re-claim in tick, which writes a fresh claim for every tunnel the
+// tunnel registry still holds; until it runs, this replica holds sockets the
+// table records nobody holding.
+//
+// PostgreSQL only, like Live: distributed mode requires it, and the interval
+// arithmetic is measured on the database's clock because liveness is compared
+// across replicas.
+//
+// Instances are written before connections, and Deregister takes the same order
+// on purpose, so the two paths cannot deadlock against each other. Here the
+// order is also forced: the connection sweep asks which instance rows survived,
+// so it has to run second.
+func (r *Registry) ReapStale(ctx context.Context, self string, within time.Duration) (instances int64, cleared int64, err error) {
+ err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // Negated rather than spelled as its own comparison: "stale" has to be
+ // exactly "not live", including how each treats a row whose last_seen
+ // is NULL, and a hand-written complement is a second definition that
+ // only looks like the first.
+ res := tx.Where("id <> ? AND NOT ("+instanceIsLive+")", self, within.Seconds()).
+ Delete(&Instance{})
+ if res.Error != nil {
+ return fmt.Errorf("deleting stale instances: %w", res.Error)
+ }
+ instances = res.RowsAffected
+
+ // Whatever survived the delete above is the live set, so this needs no
+ // second liveness rule and cannot disagree with the first one.
+ //
+ // Held rows only. An empty owner is in no instance's id, so a departed
+ // row matches the set difference too, and re-clearing it every sweep
+ // would push its departure forward five seconds at a time: it would
+ // never age out of any window measured from it, and every sweep would
+ // report clearing a connection that had already gone.
+ res = tx.Model(&NodeConnection{}).
+ Where("owner_instance_id NOT IN (SELECT id FROM instances) AND " + connectionIsHeld).
+ Updates(departure())
+ if res.Error != nil {
+ return fmt.Errorf("recording departures for orphaned node connections: %w", res.Error)
+ }
+ cleared = res.RowsAffected
+ return nil
+ })
+ if err != nil {
+ return 0, 0, fmt.Errorf("reaping stale cluster state: %w", err)
+ }
+ return instances, cleared, nil
+}
diff --git a/core/services/cluster/membership_test.go b/core/services/cluster/membership_test.go
new file mode 100644
index 000000000000..188295bb494f
--- /dev/null
+++ b/core/services/cluster/membership_test.go
@@ -0,0 +1,364 @@
+package cluster_test
+
+import (
+ "context"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+var _ = Describe("Reaping dead replicas", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ ctx context.Context
+ )
+
+ // age pushes a replica's heartbeat into the past. Sleeping in a spec is
+ // forbidden, and the liveness window is measured in tens of seconds.
+ age := func(id string, by time.Duration) {
+ GinkgoHelper()
+ Expect(db.Model(&cluster.Instance{}).Where("id = ?", id).
+ Update("last_seen", gorm.Expr("now() - make_interval(secs => ?)", by.Seconds())).Error).To(Succeed())
+ }
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ })
+
+ It("deletes a replica that stopped heartbeating, and the connections it owned", func() {
+ Expect(reg.Register(ctx, "live", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "dead", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "dead")
+ Expect(err).ToNot(HaveOccurred())
+ age("dead", time.Hour)
+
+ instances, connections, err := reg.ReapStale(ctx, "live", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(instances).To(Equal(int64(1)))
+ Expect(connections).To(Equal(int64(1)),
+ "a worker whose owner no longer exists is recorded as connected to nothing")
+
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("records a departure rather than erasing the connection when a stale replica is swept", func() {
+ // The row is what tells "this worker's owner died a moment ago" from
+ // "this worker has never connected". Deleting it on the sweep erased
+ // exactly the departure a reconnect grace has to be measured from.
+ Expect(reg.Register(ctx, "live", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "dead", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "dead")
+ Expect(err).ToNot(HaveOccurred())
+ age("dead", time.Hour)
+
+ _, _, err = reg.ReapStale(ctx, "live", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+
+ var row cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&row).Error).To(Succeed(),
+ "the sweep deleted the row, so the worker its owner left behind looks like one that never dialled")
+ Expect(row.OwnerInstanceID).To(BeEmpty())
+ Expect(row.DisconnectedAt).ToNot(BeNil())
+ })
+
+ It("does not restamp a departure it has already recorded", func() {
+ // The sweep runs every heartbeat. Re-clearing a row it already cleared
+ // would push its departure forward on every pass, so the departure
+ // would never age out of any window and the row would be reported as
+ // swept for as long as it existed.
+ Expect(reg.Register(ctx, "live", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "dead", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "dead")
+ Expect(err).ToNot(HaveOccurred())
+ age("dead", time.Hour)
+
+ _, connections, err := reg.ReapStale(ctx, "live", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(connections).To(Equal(int64(1)))
+ var first cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&first).Error).To(Succeed())
+
+ _, connections, err = reg.ReapStale(ctx, "live", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(connections).To(BeZero(), "the sweeper reported clearing a connection that was already departed")
+
+ var second cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&second).Error).To(Succeed())
+ Expect(second.DisconnectedAt).ToNot(BeNil())
+ Expect(*second.DisconnectedAt).To(Equal(*first.DisconnectedAt),
+ "the second sweep moved the departure forward, so it can never age past a grace window")
+ })
+
+ It("records a departure rather than erasing the connection when a replica deregisters", func() {
+ // The announced form of the same thing the sweeper does by inference,
+ // and the two must not disagree about what "gone" leaves behind.
+ Expect(reg.Register(ctx, "leaving", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "leaving")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(reg.Deregister(ctx, "leaving")).To(Succeed())
+
+ var row cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&row).Error).To(Succeed())
+ Expect(row.OwnerInstanceID).To(BeEmpty())
+ Expect(row.DisconnectedAt).ToNot(BeNil())
+ })
+
+ It("leaves the connections of a live replica alone", func() {
+ Expect(reg.Register(ctx, "live", "10.0.0.1:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "other", "10.0.0.2:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "other")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, connections, err := reg.ReapStale(ctx, "live", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(connections).To(BeZero())
+
+ owner, stored, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("other"))
+ Expect(stored).To(Equal(epoch))
+ })
+
+ It("never reaps the sweeper itself, however stale its own row looks", func() {
+ // A replica whose heartbeat stalled longer than the window is still
+ // serving the workers connected to it. Reaping its own row would delete
+ // their connection rows in the same pass, re-homing workers that never
+ // went anywhere.
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "me")
+ Expect(err).ToNot(HaveOccurred())
+ age("me", time.Hour)
+
+ instances, connections, err := reg.ReapStale(ctx, "me", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(instances).To(BeZero())
+ Expect(connections).To(BeZero())
+
+ owner, _, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("me"))
+ })
+
+ It("deregisters a replica and the connections it owned, so peers drop it at once", func() {
+ // Without this a cleanly stopped replica is indistinguishable from a
+ // crashed one, and every peer keeps dialling it for the whole liveness
+ // window.
+ Expect(reg.Register(ctx, "leaving", "10.0.0.2:8080", "v1")).To(Succeed())
+ Expect(reg.Register(ctx, "staying", "10.0.0.1:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "leaving")
+ Expect(err).ToNot(HaveOccurred())
+ _, err = reg.Claim(ctx, "w2", "staying")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(reg.Deregister(ctx, "leaving")).To(Succeed())
+
+ live, err := reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(HaveLen(1))
+ Expect(live[0].ID).To(Equal("staying"))
+
+ // The same rule the sweeper applies: a replica that is gone owns
+ // nothing, and a claim naming it would point every reader at an owner
+ // that no longer exists.
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ owner, _, err := reg.OwnerRow(ctx, "w2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("staying"), "deregistering one replica took another replica's claim")
+ })
+
+ It("does not restamp a departure when a replica with no id deregisters", func() {
+ // Deregister matches an owner id, and a departed row carries an empty
+ // one, so an empty id would match every departure in the table and reset
+ // each one's age. Nothing generates an empty id today, which is exactly
+ // why the filter has to be in the query rather than in that habit.
+ Expect(reg.Register(ctx, "leaving", "10.0.0.2:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "leaving")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "leaving", epoch)).To(Succeed())
+ var before cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&before).Error).To(Succeed())
+
+ Expect(reg.Deregister(ctx, "")).To(Succeed())
+
+ var after cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&after).Error).To(Succeed())
+ Expect(after.DisconnectedAt).ToNot(BeNil())
+ Expect(*after.DisconnectedAt).To(Equal(*before.DisconnectedAt),
+ "a departure that had already been recorded was stamped again, so its age restarted")
+ })
+
+ It("purges a departure that has aged past the retention, from the loop that sweeps it", func() {
+ // The retention exists only if something applies it, and this loop is
+ // the only thing that does. A PurgeDepartedBefore nobody calls leaves a
+ // row per worker that ever dialled this deployment, which is the state
+ // the sweep's held-ness filter exists to make reachable in the first
+ // place.
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "me")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "me", epoch)).To(Succeed())
+ // Aged on the database clock, past the retention the loop applies.
+ Expect(db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ (cluster.DepartedRetention + time.Minute).Seconds(), "w1").Error).To(Succeed())
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ // Polled rather than slept: the loop ticks on its own schedule, and the
+ // spec is about the call happening at all, not about when.
+ Eventually(func() (int64, error) {
+ var rows int64
+ err := db.WithContext(ctx).Model(&cluster.NodeConnection{}).Count(&rows).Error
+ return rows, err
+ }, "20s", "250ms").Should(BeZero(),
+ "the loop that sweeps dead replicas never applied the departure retention")
+ })
+
+ It("purges on the retention its reconnect grace requires, not on the floor", func() {
+ // The wiring, not the arithmetic. DepartedRetentionFor is pinned on its
+ // own in presence_test.go, and a sweep that ignored it and passed the
+ // DepartedRetention constant would satisfy every one of those
+ // expectations: the derived value is only worth anything if the loop
+ // that deletes rows is the thing reading it.
+ //
+ // A grace of 10m derives a retention of 50m, well past the 5m floor, so
+ // the two rows below straddle the difference and one answer cannot
+ // stand in for the other.
+ const grace = 10 * time.Minute
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ depart := func(node string, ago time.Duration) {
+ epoch, err := reg.Claim(ctx, node, "me")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, node, "me", epoch)).To(Succeed())
+ // Aged on the database clock, like every window in this package.
+ Expect(db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ ago.Seconds(), node).Error).To(Succeed())
+ }
+ // Past the 5m floor and inside the 50m the grace derives. A sweep
+ // applying the floor deletes this row, which is the whole defect: the
+ // purge would be outrunning the window Presence measures against, and
+ // a worker that really is gone would read as unknown for ever after.
+ depart("w-inside-derived-retention", cluster.DepartedRetention+time.Minute)
+ // Past both, so its deletion is the WITNESS that the sweep ran at all.
+ // Without it "the row survived" would also be true of a loop that never
+ // ticked, and the spec would pass on nothing happening.
+ depart("w-past-derived-retention", cluster.DepartedRetentionFor(grace)+time.Minute)
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ membership.SetReconnectGrace(grace)
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ // One assertion for both halves: rows are only ever deleted, so the set
+ // settles, and the settled set says which retention the loop applied.
+ Eventually(func() ([]string, error) {
+ var ids []string
+ err := db.WithContext(ctx).Model(&cluster.NodeConnection{}).
+ Order("node_id").Pluck("node_id", &ids).Error
+ return ids, err
+ }, "20s", "250ms").Should(Equal([]string{"w-inside-derived-retention"}),
+ "the sweep applied a retention that is not the one this replica's reconnect grace derives")
+ })
+
+ It("deregisters when the membership loop stops", func() {
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ Expect(membership.Start(ctx)).To(Succeed())
+
+ live, err := reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(HaveLen(1))
+
+ membership.Stop()
+
+ live, err = reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(BeEmpty(), "a replica that shut down cleanly left its row behind for peers to dial")
+ })
+
+ It("takes the two tables in one order, shared with the sweeper, so the two cannot deadlock", func() {
+ // A replica deregistering and a peer sweeping it run concurrently by
+ // design, and both lock rows in instances and in node_connections. In
+ // opposite orders each can end up holding the row the other waits for.
+ // The order is asserted on the SQL because the alternative, racing two
+ // transactions until they actually deadlock, is exactly the flaky spec
+ // this one replaces.
+ Expect(reg.Register(ctx, "leaving", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "leaving")
+ Expect(err).ToNot(HaveOccurred())
+
+ deregRec := newSQLRecorder()
+ Expect(cluster.NewRegistry(db.Session(&gorm.Session{Logger: deregRec})).
+ Deregister(ctx, "leaving")).To(Succeed())
+
+ reapRec := newSQLRecorder()
+ _, _, err = cluster.NewRegistry(db.Session(&gorm.Session{Logger: reapRec})).
+ ReapStale(ctx, "sweeper", time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(deregRec.writeOrder()).To(Equal([]string{"instances", "node_connections"}))
+ Expect(reapRec.writeOrder()).To(Equal(deregRec.writeOrder()),
+ "the sweeper and deregistration must lock the same two tables in the same order")
+ })
+
+ It("tolerates a repeated deregistration, because a sweeper may have got there first", func() {
+ Expect(reg.Register(ctx, "gone", "10.0.0.2:8080", "v1")).To(Succeed())
+ Expect(reg.Deregister(ctx, "gone")).To(Succeed())
+ Expect(reg.Deregister(ctx, "gone")).To(Succeed())
+ })
+
+ It("stops safely when it was never started", func() {
+ // Nothing calls this today. It exists because the loop channel is only
+ // ever closed by a started loop, so joining an unstarted one blocks
+ // forever, and phase 2 adds callers to this shutdown path.
+ membership := cluster.NewMembership(reg, "never-started", "10.0.0.1:8080", "v1")
+ done := make(chan struct{})
+ go func() {
+ defer GinkgoRecover()
+ defer close(done)
+ membership.Stop()
+ }()
+ Eventually(done, "10s").Should(BeClosed())
+ })
+
+ It("keeps this replica's row alive and reaps the dead while it runs", func() {
+ Expect(reg.Register(ctx, "dead", "10.0.0.2:8080", "v1")).To(Succeed())
+ age("dead", time.Hour)
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ // Rows, not live rows: an aged-out replica drops out of Live
+ // immediately, and what the sweeper adds is deleting it. Asserting on
+ // Live here would pass with no sweeper at all.
+ rows := func() int64 {
+ var n int64
+ if err := db.Model(&cluster.Instance{}).Count(&n).Error; err != nil {
+ return -1
+ }
+ return n
+ }
+ Expect(rows()).To(Equal(int64(2)), "the stale row is still in the table until a sweep deletes it")
+
+ Eventually(rows, 3*cluster.InstanceHeartbeat, time.Second).Should(Equal(int64(1)))
+ live, err := reg.Live(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(live).To(HaveLen(1))
+ Expect(live[0].ID).To(Equal("me"), "the sweeper deleted the wrong row")
+ })
+})
diff --git a/core/services/cluster/ownership.go b/core/services/cluster/ownership.go
new file mode 100644
index 000000000000..1f253cd073f3
--- /dev/null
+++ b/core/services/cluster/ownership.go
@@ -0,0 +1,398 @@
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// ErrNoConnection reports that no tunnel is recorded for the requested node, or
+// that the claim a caller named is no longer the live one. Callers distinguish
+// it from a transport failure to decide whether to relay through an owner, to
+// answer "this worker is not connected here", or to retry.
+var ErrNoConnection = errors.New("cluster: no connection recorded for node")
+
+// isPostgres reports whether the gorm dialect is PostgreSQL. The connection
+// fence is built out of PostgreSQL-only pieces (a sequence, ON CONFLICT
+// RETURNING), and the single-binary path runs on SQLite. advisorylock has an
+// identical private check; this one is copied rather than imported because it
+// is a one-line comparison on gorm's own dialector name and cluster is
+// deliberately a leaf package.
+func isPostgres(db *gorm.DB) bool {
+ return strings.Contains(db.Dialector.Name(), "postgres")
+}
+
+// epochSequence is the PostgreSQL sequence every claim draws its epoch from.
+// A sequence rather than a per-row counter because a connection row does not
+// outlive the deployment: a departure is purged once it is old enough, and with
+// `epoch = epoch + 1` the numbering would restart at 1 for the next claim, so a
+// replica that claimed, lost the worker, and claimed again could be handed an
+// epoch it already held, and a delayed cleanup from the first claim would then
+// match, and clear, the live one.
+const epochSequence = "node_connection_epochs"
+
+// NodeConnection records which frontend replica currently holds a worker's
+// tunnel. There is at most one row per node: a worker holds exactly one link,
+// and whoever wrote the row last owns it.
+//
+// Epoch is the fence. A worker whose link is silently broken reconnects and may
+// land on another replica before the previous owner's socket has noticed, so
+// for a while two replicas both believe they own it. Every claim draws a fresh,
+// never-reused epoch, so the loser can be told apart from the winner by a number
+// both of them hold, without either having to detect the broken socket first.
+//
+// There is deliberately no last-seen column here. Whether the owning replica is
+// alive is answered by Instance.LastSeen, and whether a claim is still the live
+// one is answered by the epoch; a second liveness clock for the same fact would
+// only drift from the first.
+type NodeConnection struct {
+ NodeID string `gorm:"primaryKey;size:36" json:"node_id"`
+ // Empty when nobody holds this tunnel. A departed row keeps the empty
+ // string rather than NULL so there is one spelling of "held by nobody";
+ // connectionIsHeld is the only place that spelling is written down.
+ OwnerInstanceID string `gorm:"size:36;index;not null" json:"owner_instance_id"`
+ Epoch int64 `gorm:"not null" json:"epoch"`
+ // No column DEFAULT: now() is PostgreSQL syntax and would reach the DDL,
+ // which breaks AutoMigrate on the SQLite single-binary path. Claim writes
+ // the database clock as an expression instead, the way Register does.
+ ConnectedAt time.Time `gorm:"not null" json:"connected_at"`
+ // DisconnectedAt is when the tunnel LEFT, and it exists because "gone for
+ // thirty seconds" and "never here" have to be different answers.
+ //
+ // A released row used to be deleted, so a worker re-homing between replicas
+ // was indistinguishable from one that had never dialled, and any grace
+ // period built on top would have had nothing to measure from. It is NOT a
+ // liveness clock for the owner: whether the OWNING replica is alive is
+ // still Instance.LastSeen and nothing else. It ticks once, on departure.
+ //
+ // Every writer in this package leaves it null while the row is held: Claim
+ // clears it in the statement that writes the owner, and only a departure
+ // sets it. That is a property of these writers, not of the table, and a
+ // mixed-version deployment breaks it: a replica running a binary from
+ // before this column existed claims without clearing the stamp, so a held
+ // row can carry the departure a newer replica recorded.
+ //
+ // So held-ness is the question, and the stamp only refines it. Ask
+ // connectionIsHeld first and read this second; a reader that reads the
+ // stamp alone reports a connected worker as gone.
+ DisconnectedAt *time.Time `gorm:"index" json:"disconnected_at,omitempty"`
+}
+
+// connectionIsHeld is the one predicate that separates a row recording a tunnel
+// somebody holds from a row recording a departure. Everything that asks that
+// question asks it here, or asks for its negation: Owner and OwnerRow refuse a
+// row it rejects, ReapStale clears only rows it accepts, and
+// PurgeDepartedBefore deletes only rows it rejects. Two spellings of one fact
+// drift, and the drift here would show up as a departed worker resolving to a
+// replica that holds nothing.
+//
+// The writes ask it too, and for the same reason Owner does rather than leaning
+// on its join: Release and Deregister already match an owner id, but that only
+// excludes a departed row while no owner id is ever empty, which is an accident
+// of who registers. An empty one would match every departure in the table and
+// reset its age.
+//
+// The column is table-qualified because Owner reads it across a join, where an
+// unqualified name is ambiguous; qualifying it costs the single-table readers
+// nothing.
+const connectionIsHeld = `node_connections.owner_instance_id <> ''`
+
+// Migrate creates every table and sequence this package owns. It is the one
+// call a caller has to remember: gorm's AutoMigrate models tables and columns
+// but has no notion of a sequence, and the connection fence draws its epochs
+// from one, so a caller that knew only about AutoMigrate would leave a schema
+// that looks complete and cannot claim. Safe to call repeatedly.
+//
+// It does not take the migration advisory lock itself. The caller holds it
+// across every table in the deployment, and taking a second one here would
+// either nest inside that one or, worse, be the reason someone stops holding
+// the outer one.
+func Migrate(ctx context.Context, db *gorm.DB) error {
+ if err := db.WithContext(ctx).AutoMigrate(&Instance{}, &NodeConnection{}); err != nil {
+ return fmt.Errorf("migrating cluster tables: %w", err)
+ }
+ return ensureEpochSequence(ctx, db)
+}
+
+// ensureEpochSequence creates the sequence Claim draws epochs from. It lives
+// here, beside the model that needs it, because gorm's AutoMigrate models
+// tables and columns but has no notion of a sequence; the caller that owns the
+// migration advisory lock calls Migrate so that concurrently starting replicas
+// do not race on the DDL. It is safe to call repeatedly.
+//
+// The sequence is not attached as a column DEFAULT on purpose: AutoMigrate
+// compares the struct's declared default against the one PostgreSQL reports
+// (`nextval('...'::regclass)`), and a mismatch there makes every startup ALTER
+// the column. Naming the sequence in the statement keeps the schema stable.
+func ensureEpochSequence(ctx context.Context, db *gorm.DB) error {
+ // CREATE SEQUENCE is PostgreSQL-only, and the same migration path runs
+ // against SQLite in single-binary mode. Nothing there can claim a
+ // connection (Claim refuses the dialect outright), so there is nothing to
+ // create.
+ if !isPostgres(db) {
+ return nil
+ }
+ if err := db.WithContext(ctx).Exec(`CREATE SEQUENCE IF NOT EXISTS ` + epochSequence + ` AS bigint`).Error; err != nil {
+ return fmt.Errorf("creating connection epoch sequence: %w", err)
+ }
+ return nil
+}
+
+// Claim records ownerID as the owner of nodeID's tunnel and returns the epoch
+// of the claim.
+//
+// The epoch is UNIQUE and never reissued: no other claim, for this node or any
+// other, is ever handed the same value. It is NOT ordered, and callers must not
+// treat it as a version number. The sequence value on the insert path is drawn
+// while the tuple is built, before the row lock, so a claim that inserts after
+// a Release can be handed a number lower than one already issued elsewhere.
+// Compare epochs for equality only; never compare them for order.
+//
+// Uniqueness is all the fence needs: Release matches owner and epoch exactly,
+// so a stale claim's token cannot match a live claim's row whichever way the
+// two numbers happen to compare.
+//
+// It is one statement on purpose. A read-then-write would let two replicas read
+// the same epoch and hand out the same fence token, which is exactly the case
+// the fence exists to rule out; PostgreSQL serializes concurrent
+// INSERT ... ON CONFLICT DO UPDATE on the conflicting row, so the losing writers
+// block until the winner commits and only then draw their own epoch, in the
+// order they took the row lock.
+func (r *Registry) Claim(ctx context.Context, nodeID, ownerID string) (int64, error) {
+ // Refused rather than attempted on a dialect with no sequence. The
+ // statement would fail anyway, but with a driver-level "no such function:
+ // nextval" that reads like a missing migration; and a fence that cannot
+ // issue a token must not look like one that did.
+ if !isPostgres(r.db) {
+ return 0, fmt.Errorf("claiming connection for node %q as %q: connection ownership requires PostgreSQL, this deployment runs on %q", nodeID, ownerID, r.db.Dialector.Name())
+ }
+ // connected_at is stamped by the database, never by this process, for the
+ // same reason instance liveness is: it is compared across replicas, so it
+ // has to be measured on the one clock every replica shares.
+ nextEpoch := gorm.Expr("nextval('" + epochSequence + "')")
+ values := map[string]any{
+ "node_id": nodeID,
+ "owner_instance_id": ownerID,
+ "epoch": nextEpoch,
+ "connected_at": gorm.Expr("now()"),
+ }
+ if err := r.db.WithContext(ctx).Model(&NodeConnection{}).Clauses(
+ clause.OnConflict{
+ Columns: []clause.Column{{Name: "node_id"}},
+ DoUpdates: clause.Assignments(map[string]any{
+ "owner_instance_id": ownerID,
+ "epoch": nextEpoch,
+ "connected_at": gorm.Expr("now()"),
+ // In the SAME statement as the owner, so a reconnect is never
+ // observed half-applied: a row that names a holder while still
+ // carrying a departure would answer "held" and "gone" at once.
+ "disconnected_at": nil,
+ }),
+ },
+ clause.Returning{Columns: []clause.Column{{Name: "epoch"}}},
+ ).Create(values).Error; err != nil {
+ return 0, fmt.Errorf("claiming connection for node %q as %q: %w", nodeID, ownerID, err)
+ }
+ // gorm scans RETURNING back over the map it was handed. If that ever stops
+ // happening the entry is still the expression we passed in, and returning a
+ // bogus epoch would hand out a fence token the database never issued.
+ epoch, ok := values["epoch"].(int64)
+ if !ok {
+ return 0, fmt.Errorf("claiming connection for node %q as %q: epoch not returned by the database (got %T)", nodeID, ownerID, values["epoch"])
+ }
+ return epoch, nil
+}
+
+// OwnerRow returns the row recording which replica holds nodeID's tunnel, and
+// the epoch of that claim, or ErrNoConnection when the node has no recorded
+// connection.
+//
+// It answers "what does the table say", NOT "who holds this tunnel". The owner
+// it names may be dead: a replica that dies stops heartbeating, and its rows
+// survive until another replica's sweep removes them, which is up to
+// InstanceLiveness plus one InstanceHeartbeat later.
+//
+// Anything that needs to know WHO owns a node in order to act on it, a dialer
+// above all, wants Owner: it joins instances and treats a non-live owner as
+// ErrNoConnection. Dialing what this function returns is dialing a process that
+// may be gone.
+//
+// What is left for this one is observing the table as such, independently of
+// liveness. Its callers today are this package's specs, including the one that
+// holds the two reads apart, and the e2e cluster spec that watches ownership
+// move between replicas. No production caller reads it, and the sweeper is not
+// one: ReapStale finds orphans with a set difference in SQL.
+//
+// A row that records a departure is ErrNoConnection here too. The row survives
+// so that something can decide how old the departure is; what it says about who
+// holds the tunnel is nobody, and this read reports exactly that.
+func (r *Registry) OwnerRow(ctx context.Context, nodeID string) (string, int64, error) {
+ var conn NodeConnection
+ err := r.db.WithContext(ctx).
+ Where("node_id = ? AND "+connectionIsHeld, nodeID).First(&conn).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", 0, fmt.Errorf("looking up owner of node %q: %w", nodeID, ErrNoConnection)
+ }
+ if err != nil {
+ return "", 0, fmt.Errorf("looking up owner of node %q: %w", nodeID, err)
+ }
+ return conn.OwnerInstanceID, conn.Epoch, nil
+}
+
+// Owner returns the replica that holds nodeID's tunnel AND is still live, with
+// the epoch of that claim, or ErrNoConnection when there is no such replica.
+//
+// This is the read anything that ACTS on the answer must use. A connection row
+// outlives its owner: a replica that dies stops heartbeating but its rows stay
+// until a peer's sweep removes them, which is up to InstanceLiveness plus one
+// InstanceHeartbeat later. For that whole window OwnerRow names a process that
+// is gone, and a relay built on it would dial a corpse and report the worker as
+// unreachable rather than as absent.
+//
+// A missing row, a departed row and a dead owner are one answer on purpose. All
+// three mean "no live replica holds this worker's tunnel", which is what a
+// caller decides on; they differ only in which sweep has already run, and that
+// is the sweeper's business rather than the caller's.
+//
+// That answer is emphatically not "this worker is gone". How long ago the
+// departure was recorded is what separates a worker re-homing between replicas
+// from one that has left, and this read does not report it: a caller that has
+// to tell those apart reads the departure, not this.
+//
+// One statement, joined, not a row read followed by an instance lookup: between
+// two statements the owner can die, and the caller would act on an owner the
+// second read would have rejected. The join makes the two facts one snapshot.
+//
+// The window is InstanceLiveness rather than a parameter, which is the window
+// the membership loop sweeps with. A caller free to pick its own could keep
+// relaying to a replica the sweeper has already declared dead, or give up on
+// one the sweeper is still keeping.
+func (r *Registry) Owner(ctx context.Context, nodeID string) (string, int64, error) {
+ // Refused rather than attempted, for the reason Claim refuses: now() and
+ // make_interval are PostgreSQL, so on the single-binary SQLite path this
+ // would fail with "no such function: now", which reads as a missing
+ // migration. It is deliberately not ErrNoConnection. A deployment with no
+ // cluster has no answer to give about who owns a tunnel, and reporting
+ // absence would let a caller conclude the worker is not connected.
+ if !isPostgres(r.db) {
+ return "", 0, fmt.Errorf("looking up live owner of node %q: connection ownership requires PostgreSQL, this deployment runs on %q", nodeID, r.db.Dialector.Name())
+ }
+ var conn NodeConnection
+ err := r.db.WithContext(ctx).
+ Model(&NodeConnection{}).
+ // Not load-bearing: gorm already expands this model's own columns,
+ // table-qualified, when a join is present and nothing was selected
+ // (callbacks.BuildQuerySQL). Written out so the projection is a
+ // property of this query rather than of that behaviour, since the join
+ // is here to filter and the row scanned back must stay this table's.
+ Select("node_connections.*").
+ Joins("JOIN instances ON instances.id = node_connections.owner_instance_id AND "+instanceIsLive, InstanceLiveness.Seconds()).
+ // The departure filter is this query's own, not the join's. An empty
+ // owner matches no instance today only because no replica registers
+ // under an empty id, which is an accident of who registers rather than
+ // a property of ownership.
+ Where("node_connections.node_id = ? AND "+connectionIsHeld, nodeID).
+ Take(&conn).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", 0, fmt.Errorf("looking up live owner of node %q: %w", nodeID, ErrNoConnection)
+ }
+ if err != nil {
+ return "", 0, fmt.Errorf("looking up live owner of node %q: %w", nodeID, err)
+ }
+ return conn.OwnerInstanceID, conn.Epoch, nil
+}
+
+// departure is the single assignment that turns a held row into a departed one.
+// Release writes it for one claim and the membership sweep writes it for every
+// claim a dead replica held, and the two must not disagree about what a
+// departure looks like: a row cleared without a stamp, or stamped without being
+// cleared, is a state every reader here has no answer for.
+//
+// The timestamp is the database's, never this process's, for the same reason
+// instance liveness is: it is compared across replicas, so it has to be
+// measured on the one clock they share.
+func departure() map[string]any {
+ return map[string]any{
+ "owner_instance_id": "",
+ "disconnected_at": gorm.Expr("now()"),
+ }
+}
+
+// Release drops the claim identified by ownerID and epoch, and records the
+// departure: the row survives with no owner and a disconnected_at stamp.
+//
+// An UPDATE and not a DELETE, because the row is the only place a departure can
+// be recorded. Deleting it made a worker re-homing between replicas look like
+// one that had never connected, so nothing above could tell a two-second blip
+// from a worker that is gone.
+//
+// Both ownerID and epoch are in the WHERE so a replica that has only just
+// noticed its dead socket cannot touch the claim a later reconnect established
+// elsewhere: it must not clear a live claim, and it must not stamp a departure
+// onto one. A claim that is no longer the live one is reported as
+// ErrNoConnection rather than silently ignored, because the caller learning it
+// has been fenced out is the point.
+func (r *Registry) Release(ctx context.Context, nodeID, ownerID string, epoch int64) error {
+ // Refused rather than attempted, for the reason Claim refuses: the
+ // departure is stamped with now(), which on the single-binary SQLite path
+ // fails as a missing function and reads as a missing migration. It is
+ // deliberately not ErrNoConnection: a deployment with no cluster holds no
+ // claims, and reporting a fenced-out release would tell the caller it lost
+ // one.
+ if !isPostgres(r.db) {
+ return fmt.Errorf("releasing connection for node %q held by %q: connection ownership requires PostgreSQL, this deployment runs on %q", nodeID, ownerID, r.db.Dialector.Name())
+ }
+ // gorm reports no error when a Where matches nothing, so the miss has to be
+ // read off RowsAffected.
+ res := r.db.WithContext(ctx).
+ Model(&NodeConnection{}).
+ // The held-ness filter is not implied by the owner match: a departed row
+ // keeps its epoch, so a release naming an empty owner would match it and
+ // stamp a fresh departure over the old one, making a worker that left
+ // long ago look like one that has only just gone.
+ Where("node_id = ? AND owner_instance_id = ? AND epoch = ? AND "+connectionIsHeld, nodeID, ownerID, epoch).
+ Updates(departure())
+ if res.Error != nil {
+ return fmt.Errorf("releasing connection for node %q held by %q at epoch %d: %w", nodeID, ownerID, epoch, res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return fmt.Errorf("releasing connection for node %q held by %q at epoch %d: %w", nodeID, ownerID, epoch, ErrNoConnection)
+ }
+ return nil
+}
+
+// PurgeDepartedBefore deletes the connection rows whose departure is older than
+// olderThan, and returns how many went.
+//
+// Departures are kept so that absence can be decided, not kept forever. The
+// retention has to outlast every window measured from a departure by enough
+// that a purge can never turn a worker inside its reconnect grace into a worker
+// that was never here; the caller passes a multiple of that grace, never the
+// grace itself.
+//
+// A held row is never touched, whatever timestamp it carries: deleting one
+// strands a worker that is connected at that moment. The age is measured by the
+// database, like every other window here, so no replica's clock decides how old
+// another replica's departure is.
+func (r *Registry) PurgeDepartedBefore(ctx context.Context, olderThan time.Duration) (int64, error) {
+ // Refused rather than attempted, for the reason Claim refuses: now() and
+ // make_interval are PostgreSQL, and on the single-binary SQLite path this
+ // would fail with "no such function: now", which reads as a missing
+ // migration.
+ if !isPostgres(r.db) {
+ return 0, fmt.Errorf("purging departed connections: connection ownership requires PostgreSQL, this deployment runs on %q", r.db.Dialector.Name())
+ }
+ res := r.db.WithContext(ctx).
+ Where("NOT ("+connectionIsHeld+") AND node_connections.disconnected_at < now() - make_interval(secs => ?)",
+ olderThan.Seconds()).
+ Delete(&NodeConnection{})
+ if res.Error != nil {
+ return 0, fmt.Errorf("purging departed connections: %w", res.Error)
+ }
+ return res.RowsAffected, nil
+}
diff --git a/core/services/cluster/ownership_test.go b/core/services/cluster/ownership_test.go
new file mode 100644
index 000000000000..54195cfd86a1
--- /dev/null
+++ b/core/services/cluster/ownership_test.go
@@ -0,0 +1,642 @@
+package cluster_test
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+)
+
+// sqlRecorder captures the statements gorm actually sends, so a spec can assert
+// on the SQL rather than on gorm's intent. gorm silently drops clauses it
+// cannot apply to a given destination, and such a drop turns an atomic upsert
+// into something that still passes every sequential expectation.
+type sqlRecorder struct {
+ gormlogger.Interface
+ mu sync.Mutex
+ statements []string
+ errs []error
+}
+
+func newSQLRecorder() *sqlRecorder {
+ return &sqlRecorder{Interface: gormlogger.Default.LogMode(gormlogger.Silent)}
+}
+
+func (r *sqlRecorder) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
+ sql, rows := fc()
+ r.mu.Lock()
+ r.statements = append(r.statements, sql)
+ if err != nil {
+ r.errs = append(r.errs, err)
+ }
+ r.mu.Unlock()
+ // Delegate so a failing statement is still reported the way gorm would
+ // report it. An instrument used to prove what the SQL does must not be the
+ // one thing that hides a statement erroring.
+ r.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
+}
+
+// only returns the single recorded statement, failing the spec if the call
+// under test issued anything other than exactly one.
+func (r *sqlRecorder) only() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ ExpectWithOffset(1, r.errs).To(BeEmpty(), "the recorded statement failed")
+ ExpectWithOffset(1, r.statements).To(HaveLen(1), "expected exactly one statement, got: %v", r.statements)
+ return r.statements[0]
+}
+
+// writeTarget matches the table a statement writes to, anchored at the verb so
+// the UPDATE inside an upsert's ON CONFLICT clause cannot be mistaken for one.
+var writeTarget = regexp.MustCompile(`^\s*(?i:delete\s+from|update)\s+"?([a-z_]+)"?`)
+
+// writeOrder returns the tables the recorded statements wrote to, in the order
+// they were issued. It is how a spec pins a lock order: the order two paths
+// take the same tables in is a property of the SQL, and asserting it on an
+// outcome instead would mean racing two transactions into a real deadlock.
+//
+// Deletes and updates count alike. What deadlocks two transactions is the order
+// they take row locks in, and an update takes the same lock a delete does, so a
+// path that stopped deleting a table and started updating it would still have
+// to keep the order.
+func (r *sqlRecorder) writeOrder() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ ExpectWithOffset(1, r.errs).To(BeEmpty(), "a recorded statement failed")
+ var tables []string
+ for _, stmt := range r.statements {
+ if m := writeTarget.FindStringSubmatch(stmt); m != nil {
+ tables = append(tables, m[1])
+ }
+ }
+ return tables
+}
+
+var _ = Describe("Connection ownership", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ })
+
+ It("hands every claim an epoch no other claim was given", func() {
+ // Uniqueness, not order. Claim's contract is that no two claims ever
+ // share an epoch; the insert path draws its sequence value before the
+ // row lock, so a claim that follows a Release can be handed a lower
+ // number than one already issued. Asserting e2 > e1 here would pin an
+ // ordering the fence does not need and does not promise.
+ e1, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ e2, err := reg.Claim(ctx, "w1", "inst-b")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(e2).ToNot(Equal(e1))
+ })
+
+ It("reports the latest owner", func() {
+ _, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ e2, err := reg.Claim(ctx, "w1", "inst-b")
+ Expect(err).ToNot(HaveOccurred())
+
+ owner, epoch, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-b"))
+ Expect(epoch).To(Equal(e2), "the stored epoch must be the one the winning claim was handed")
+ })
+
+ It("distinguishes an unknown connection", func() {
+ _, _, err := reg.OwnerRow(ctx, "ghost")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("refuses a release from a stale owner", func() {
+ e1, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ _, err = reg.Claim(ctx, "w1", "inst-b")
+ Expect(err).ToNot(HaveOccurred())
+
+ // inst-a tries to clean up after losing the claim.
+ Expect(reg.Release(ctx, "w1", "inst-a", e1)).ToNot(Succeed())
+
+ owner, _, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-b"), "a stale owner must not be able to delete a live claim")
+ })
+
+ It("refuses a release that names the live owner but a stale epoch", func() {
+ // The same replica can reconnect a worker to itself; only the epoch
+ // separates the dead link from the live one.
+ e1, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ _, err = reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(reg.Release(ctx, "w1", "inst-a", e1)).ToNot(Succeed())
+
+ owner, _, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-a"))
+ })
+
+ It("never hands a node the same epoch twice, so a delayed cleanup cannot delete a live claim", func() {
+ // The scenario the fence exists for, with a release in the middle of it:
+ // inst-a claims and its link then dies silently.
+ eA1, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ // The worker reconnects to inst-b, which later releases cleanly.
+ eB, err := reg.Claim(ctx, "w1", "inst-b")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-b", eB)).To(Succeed())
+ // The worker comes back to inst-a, which is the same process throughout,
+ // so the owner id alone cannot separate this claim from the dead one.
+ eA2, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ // inst-a finally notices the first link is dead and cleans up after it.
+ // The harm is asserted before the cause, so a regression fails on the
+ // live claim disappearing rather than on the epoch arithmetic.
+ Expect(reg.Release(ctx, "w1", "inst-a", eA1)).ToNot(Succeed())
+
+ owner, epoch, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred(), "the delayed cleanup deleted the live claim")
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(eA2))
+ Expect(eA2).ToNot(Equal(eA1), "an epoch handed out before a release must never be handed out again")
+ })
+
+ It("lets the current owner release its own claim", func() {
+ e, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-a", e)).To(Succeed())
+
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ // Owner is the resolving read: it answers "who holds this tunnel and can be
+ // relayed to", where OwnerRow answers "what does the table say". The gap
+ // between the two is a whole liveness window wide, because a replica that
+ // dies leaves its connection rows behind until a peer's sweep removes them.
+ Describe("resolving the owner that can actually be relayed to", func() {
+ // Just past the window the membership loop sweeps with, not an arbitrary
+ // large age: a row aged ten minutes is rejected by any window between
+ // zero and ten minutes, so it would pin "filtered by SOME window" while
+ // letting Owner and the sweeper drift apart. The two seconds keep the
+ // spec off the exact boundary without loosening what it holds.
+ agedOut := cluster.InstanceLiveness + 2*time.Second
+ // Old enough that a narrowed window would reject it, still inside the
+ // one Owner must use. It is the other half of the same pin: agedOut
+ // fails a widened window, this fails a narrowed one.
+ agedButLive := cluster.InstanceLiveness / 2
+
+ // age rewrites an instance's heartbeat into the past. Sleeping for a
+ // liveness window is forbidden in a spec, and would be measuring the
+ // clock rather than the query.
+ age := func(id string, by time.Duration) {
+ ExpectWithOffset(1, db.Model(&cluster.Instance{}).Where("id = ?", id).
+ Update("last_seen", time.Now().Add(-by)).Error).To(Succeed())
+ }
+
+ It("names an owner whose replica is live", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ claimed, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ owner, epoch, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(claimed), "the resolved epoch must be the fence token the claim was handed")
+ })
+
+ It("still names an owner whose heartbeat is old but inside the window", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ claimed, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age("inst-a", agedButLive)
+
+ owner, epoch, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred(), "a replica within the sweeper's window is alive, and its workers are still reachable through it")
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(claimed))
+ })
+
+ It("refuses to name an owner that has no instance row at all", func() {
+ // What a completed sweep leaves for the moment between deleting the
+ // instance row and deleting the connections it orphaned, and what a
+ // re-registering replica's own connection rows look like meanwhile.
+ _, err := reg.Claim(ctx, "w1", "inst-gone")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, _, err = reg.Owner(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("refuses to name an owner whose heartbeat has aged past the liveness window", func() {
+ // The window this task exists to close: the replica is dead, no peer
+ // has swept it yet, and the row still names it.
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age("inst-a", agedOut)
+
+ _, _, err = reg.Owner(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("names an owner again once its heartbeat comes back", func() {
+ // Liveness is a window, not a latch: a replica that stalls and
+ // recovers still owns the sockets it never dropped, so resolution
+ // has to follow last_seen rather than remember a verdict.
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ claimed, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age("inst-a", agedOut)
+ Expect(reg.Heartbeat(ctx, "inst-a")).To(Succeed())
+
+ owner, epoch, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(claimed))
+ })
+
+ It("still reports the dead owner through OwnerRow, which is why the two reads are separate", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ claimed, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age("inst-a", agedOut)
+
+ owner, epoch, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred(), "OwnerRow reads the row and nothing else; hiding the dead owner here would leave the sweeper with no way to see what it has to clean up")
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(claimed))
+
+ _, _, err = reg.Owner(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection), "Owner and OwnerRow must not agree here, or one of them is redundant")
+ })
+
+ It("reports a node with no connection at all the same way", func() {
+ _, _, err := reg.Owner(ctx, "ghost")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("resolves in one joined statement measured on the database clock", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ rec := newSQLRecorder()
+ recording := cluster.NewRegistry(db.Session(&gorm.Session{Logger: rec}))
+ _, _, err = recording.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+
+ sql := strings.ToLower(rec.only())
+ // only() rules out the read-then-look-up shape: two statements
+ // leave a window in which the owner dies between them, which is the
+ // race the join closes.
+ Expect(sql).To(ContainSubstring("join"))
+ Expect(sql).To(ContainSubstring("instances"))
+ // Liveness is compared across replicas, so the cutoff has to be
+ // computed on the one clock they all share. A Go-side time.Now()
+ // would appear as a bound parameter and a plain comparison instead,
+ // and replica clock skew would then widen or narrow the window.
+ Expect(sql).To(ContainSubstring("now()"))
+ Expect(sql).To(ContainSubstring("make_interval"))
+ Expect(sql).ToNot(MatchRegexp(`last_seen\s*>\s*'`),
+ "the liveness cutoff must not be a literal timestamp from this process's clock")
+ })
+ })
+
+ It("claims in one statement that draws its epoch from the database sequence and stamps on the database clock", func() {
+ rec := newSQLRecorder()
+ recording := cluster.NewRegistry(db.Session(&gorm.Session{Logger: rec}))
+
+ _, err := recording.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ sql := strings.ToLower(rec.only())
+ // A read-then-write would show up here as two statements; the length
+ // check in only() is what rules that out. The rest pins the parts a
+ // silently dropped clause would remove.
+ Expect(sql).To(ContainSubstring("on conflict"))
+ Expect(sql).To(MatchRegexp(`(?i)nextval\s*\(\s*'node_connection_epochs'\s*\)`),
+ "the epoch must be drawn by the database, not computed by this process")
+ Expect(sql).To(ContainSubstring("returning"))
+ Expect(sql).To(ContainSubstring(`"epoch"`))
+ // Timestamps are compared across replicas, so they must be measured on
+ // the one clock every replica shares. A Go-side time.Now() would appear
+ // as a bound parameter instead.
+ Expect(sql).To(ContainSubstring("now()"))
+ Expect(sql).ToNot(MatchRegexp(`connected_at"?\s*=\s*'`),
+ "connected_at must not be a literal timestamp from this process's clock")
+ })
+
+ It("gives every concurrent claimant a distinct epoch and leaves exactly one winner", func() {
+ const claimants = 8
+ epochs := make(chan int64, claimants)
+ var wg sync.WaitGroup
+ for i := 0; i < claimants; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ defer GinkgoRecover()
+ e, err := reg.Claim(context.Background(), "w-race", fmt.Sprintf("inst-%d", n))
+ Expect(err).ToNot(HaveOccurred())
+ epochs <- e
+ }(i)
+ }
+ wg.Wait()
+ close(epochs)
+
+ seen := map[int64]bool{}
+ for e := range epochs {
+ Expect(seen[e]).To(BeFalse(), "epoch %d handed out twice; the fence is not atomic", e)
+ seen[e] = true
+ }
+ Expect(seen).To(HaveLen(claimants))
+
+ // Exactly one row, holding one of the epochs that was handed out: a
+ // winner, not a value nobody was given. Which of the eight wins is not
+ // asserted, and neither is any ordering among them. Epochs are unique
+ // and unordered, and a spec that ranked them here would teach the
+ // opposite of what Claim documents, whatever the sequence happens to do
+ // on this path.
+ var rows []cluster.NodeConnection
+ Expect(db.Where("node_id = ?", "w-race").Find(&rows).Error).To(Succeed())
+ Expect(rows).To(HaveLen(1))
+ Expect(seen).To(HaveKey(rows[0].Epoch), "the stored epoch was never handed to any claimant")
+ })
+
+ // A tunnel that goes away has to leave a mark. Deleting the row made "this
+ // worker's link dropped a moment ago" and "this worker has never connected
+ // here" one observation, so nothing above could tell a reconnect in flight
+ // from a departure, and every grace period built on top would have had
+ // nothing to measure from.
+ Describe("recording a departure", func() {
+ It("keeps the row and stamps disconnected_at when a claim is released", func() {
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(reg.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+
+ var row cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&row).Error).To(Succeed(),
+ "the released row was deleted, so a worker that just left is indistinguishable from one that never dialled")
+ Expect(row.OwnerInstanceID).To(BeEmpty())
+ Expect(row.DisconnectedAt).ToNot(BeNil())
+ })
+
+ It("records the departure with one update measured on the database clock", func() {
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ rec := newSQLRecorder()
+ recording := cluster.NewRegistry(db.Session(&gorm.Session{Logger: rec}))
+ Expect(recording.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+
+ sql := strings.ToLower(rec.only())
+ Expect(sql).To(ContainSubstring("update"))
+ Expect(sql).ToNot(ContainSubstring("delete"))
+ // The departure is compared against a grace window by other
+ // replicas, so it has to be stamped on the one clock they share. A
+ // Go-side time.Now() would appear as a bound parameter instead, and
+ // clock skew would then widen or narrow every window built on it.
+ Expect(sql).To(ContainSubstring("now()"))
+ Expect(sql).ToNot(MatchRegexp(`disconnected_at"?\s*=\s*'`),
+ "the departure must not be a literal timestamp from this process's clock")
+ })
+
+ It("reports a released row as no connection from both reads", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+
+ // Both reads: the row surviving is for whoever decides how old the
+ // departure is, and until something does, a departed row is no
+ // connection to a dialer and no connection to an observer.
+ _, _, err = reg.Owner(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("does not resolve a departed row through an instance whose id is empty", func() {
+ // Owner rejects a departed row itself rather than leaning on the
+ // instances join to miss it. The join only misses an empty owner
+ // for as long as no instance row carries an empty id, which is an
+ // accident of who registers rather than a property of ownership.
+ Expect(reg.Register(ctx, "", "10.0.0.1:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+
+ _, _, err = reg.Owner(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("clears the departure when the worker reconnects", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+
+ again, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(again).ToNot(Equal(epoch), "uniqueness, never ordering")
+
+ var row cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&row).Error).To(Succeed())
+ Expect(row.DisconnectedAt).To(BeNil(),
+ "a row with an owner still carried a departure, so a reader has two answers to choose from")
+ owner, _, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-a"))
+ })
+
+ It("does not let a fenced-out replica stamp a departure onto the live claim", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ stale, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ live, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(reg.Release(ctx, "w1", "inst-a", stale)).To(MatchError(cluster.ErrNoConnection))
+
+ owner, epoch, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(live))
+ var row cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&row).Error).To(Succeed())
+ Expect(row.DisconnectedAt).To(BeNil(),
+ "a replica that only just noticed its dead socket marked a live tunnel as departed")
+ })
+
+ It("refuses a release that names no owner, so a recorded departure cannot be aged backwards", func() {
+ // A departed row keeps the epoch of the claim that left, and its
+ // owner is the empty string, so a release naming an empty owner
+ // matches it on both columns. Stamping a fresh departure there would
+ // make a worker that left long ago look like one that has only just
+ // gone, which is the difference every window above is measured from.
+ epoch, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "w1", "inst-a", epoch)).To(Succeed())
+ var before cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&before).Error).To(Succeed())
+
+ Expect(reg.Release(ctx, "w1", "", epoch)).To(MatchError(cluster.ErrNoConnection))
+
+ var after cluster.NodeConnection
+ Expect(db.WithContext(ctx).Where("node_id = ?", "w1").First(&after).Error).To(Succeed())
+ Expect(after.DisconnectedAt).ToNot(BeNil())
+ Expect(*after.DisconnectedAt).To(Equal(*before.DisconnectedAt))
+ })
+
+ It("purges a departure older than the retention and keeps a recent one", func() {
+ oldEpoch, err := reg.Claim(ctx, "old", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "old", "inst-a", oldEpoch)).To(Succeed())
+ // Aged on the database clock, which is the clock the purge measures
+ // on. Sleeping out a retention window in a spec is forbidden, and
+ // would be measuring this process's clock rather than the query.
+ Expect(db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ 3600, "old").Error).To(Succeed())
+
+ freshEpoch, err := reg.Claim(ctx, "fresh", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "fresh", "inst-a", freshEpoch)).To(Succeed())
+
+ purged, err := reg.PurgeDepartedBefore(ctx, 10*time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(purged).To(Equal(int64(1)))
+
+ var remaining []cluster.NodeConnection
+ Expect(db.WithContext(ctx).Find(&remaining).Error).To(Succeed())
+ Expect(remaining).To(HaveLen(1))
+ Expect(remaining[0].NodeID).To(Equal("fresh"),
+ "the purge took a departure that is still inside every window built on it")
+ })
+
+ It("measures the retention on the database clock", func() {
+ rec := newSQLRecorder()
+ recording := cluster.NewRegistry(db.Session(&gorm.Session{Logger: rec}))
+ _, err := recording.PurgeDepartedBefore(ctx, 10*time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+
+ sql := strings.ToLower(rec.only())
+ // Asserted on the statement because no outcome can separate the two
+ // here: every replica in a spec shares this host's clock, so a
+ // cutoff computed in Go agrees with the database's until two
+ // machines disagree, and then one replica purges departures its
+ // peers still consider recent.
+ Expect(sql).To(ContainSubstring("now()"))
+ Expect(sql).To(ContainSubstring("make_interval"))
+ Expect(sql).ToNot(MatchRegexp(`disconnected_at"?\s*<\s*'`),
+ "the retention cutoff must not be a literal timestamp from this process's clock")
+ })
+
+ It("never purges a row that is still held, whatever timestamp it carries", func() {
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ claimed, err := reg.Claim(ctx, "w1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ // No writer produces a held row carrying a departure, so it is
+ // written here directly: the purge has to be pinned on the owner
+ // column rather than on the timestamp happening to be null, because
+ // deleting a held row strands a worker that is connected right now.
+ Expect(db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ 3600, "w1").Error).To(Succeed())
+
+ purged, err := reg.PurgeDepartedBefore(ctx, time.Minute)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(purged).To(BeZero())
+
+ owner, epoch, err := reg.Owner(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred(), "the purge deleted the row of a tunnel somebody holds")
+ Expect(owner).To(Equal("inst-a"))
+ Expect(epoch).To(Equal(claimed))
+ })
+ })
+})
+
+var _ = Describe("Connection ownership on a non-PostgreSQL dialect", func() {
+ var (
+ db *gorm.DB
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ var err error
+ ctx = context.Background()
+ db, err = gorm.Open(sqlite.Open(filepath.Join(GinkgoT().TempDir(), "cluster.db")), &gorm.Config{})
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("migrates, because the single-binary path shares this schema", func() {
+ // A PostgreSQL-only column DEFAULT here breaks AutoMigrate for every
+ // SQLite caller of nodes.NewNodeRegistry, which is how this regressed.
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ })
+
+ It("refuses to resolve an owner, rather than failing as a missing function", func() {
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+
+ _, _, err := cluster.NewRegistry(db).Owner(ctx, "w1")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("requires PostgreSQL"))
+ Expect(err.Error()).ToNot(ContainSubstring("no such function"),
+ "a dialect that cannot answer must say so, not surface as a missing migration")
+ Expect(err).ToNot(MatchError(cluster.ErrNoConnection),
+ "a deployment with no cluster has no answer about ownership; reporting absence would let a caller conclude the worker is not connected")
+ })
+
+ It("refuses to claim, rather than pretending to fence", func() {
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+
+ _, err := cluster.NewRegistry(db).Claim(ctx, "w1", "inst-a")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("requires PostgreSQL"))
+ })
+
+ It("refuses to release, rather than clearing a claim outside the fence", func() {
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+
+ err := cluster.NewRegistry(db).Release(ctx, "w1", "inst-a", 1)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("requires PostgreSQL"))
+ Expect(err.Error()).ToNot(ContainSubstring("no such function"),
+ "a dialect that cannot record a departure must say so, not surface as a missing migration")
+ Expect(err).ToNot(MatchError(cluster.ErrNoConnection),
+ "a deployment with no cluster holds no claims; reporting a fenced-out release would let a caller conclude it lost one")
+ })
+
+ It("refuses to purge departures, rather than failing as a missing function", func() {
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+
+ _, err := cluster.NewRegistry(db).PurgeDepartedBefore(ctx, time.Minute)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("requires PostgreSQL"))
+ Expect(err.Error()).ToNot(ContainSubstring("no such function"),
+ "a dialect that cannot answer must say so, not surface as a missing migration")
+ })
+})
diff --git a/core/services/cluster/peerlink.go b/core/services/cluster/peerlink.go
new file mode 100644
index 000000000000..fec713891802
--- /dev/null
+++ b/core/services/cluster/peerlink.go
@@ -0,0 +1,395 @@
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/libp2p/go-yamux/v5"
+ "github.com/mudler/xlog"
+)
+
+// PeerPath is the route a replica dials to open a peer link, and the route the
+// HTTP layer registers the handler on. It lives here, with the dialler and the
+// WebSocket adapter, so that core/services/cluster stays a leaf: the HTTP
+// endpoints package imports this one, never the other way round.
+//
+// The literal is spelled out rather than derived from auth.ClusterPathPrefix
+// because importing core/http/auth is exactly the dependency this package must
+// not have. The two are kept from drifting apart by a spec in the endpoints
+// package, which can see both.
+const PeerPath = "/api/cluster/peer"
+
+// ErrPeerUnreachable reports that a peer this deployment knows about could not
+// be reached: the dial failed, the peer refused the credentials, or its
+// multiplexer would not carry a stream.
+//
+// It is deliberately NOT a form of ErrInstanceNotFound, and the two must stay
+// unmixable. A caller that sees absence is entitled to conclude a node is gone
+// and reclaim what it was running; a caller that sees unreachability may only
+// retry. Collapsing the two means a network hiccup between two healthy
+// replicas evicts healthy workers. See unreachableError for how that is
+// enforced rather than merely documented.
+var ErrPeerUnreachable = errors.New("cluster: peer unreachable")
+
+// unreachableError reports a peer that could not be reached, keeping the
+// underlying cause in its message and out of its unwrap chain.
+//
+// Withholding the cause from errors.Is is the point. The dial path resolves a
+// peer's address through the registry, so ErrInstanceNotFound is a cause this
+// error can genuinely be built over: a row deleted between two attempts, for
+// one. If the cause were unwrapped, that failure would satisfy both sentinels
+// at once and every caller's absence check would fire on a transport problem.
+// The guarantee therefore belongs to the type: no call site can leak absence
+// through it, because there is no path by which absence gets out.
+type unreachableError struct {
+ peerID string
+ cause error
+}
+
+func (e *unreachableError) Error() string {
+ return fmt.Sprintf("cluster: peer %q unreachable: %v", e.peerID, e.cause)
+}
+
+// Unwrap reports only ErrPeerUnreachable. The cause reaches a human through
+// Error() and reaches no error-matching caller at all.
+func (e *unreachableError) Unwrap() error { return ErrPeerUnreachable }
+
+func unreachablePeer(peerID string, cause error) error {
+ return &unreachableError{peerID: peerID, cause: cause}
+}
+
+// ErrPoolClosed reports an Open on a pool that has been shut down. It is a
+// third condition on purpose: the pool being closed is a fact about this
+// process and says nothing about whether the peer exists or answers.
+var ErrPoolClosed = errors.New("cluster: peer pool is closed")
+
+const (
+ // peerLinkHandshakeTimeout bounds the WebSocket upgrade. It also bounds
+ // how long Close can wait behind an in-flight dial, since a dial holds the
+ // per-peer lock Close needs to reach the cached session.
+ peerLinkHandshakeTimeout = 10 * time.Second
+
+ // peerLinkInitialWindow is the per-stream receive window every stream on a
+ // peer link starts at, raised from yamux's 256 KiB default.
+ //
+ // yamux already bounds head-of-line blocking with MaxMessageSize (64 KiB
+ // by default), so one stream cannot monopolise the connection whatever the
+ // window is. What the small default costs is the ramp: a stream carrying a
+ // multi-megabyte gRPC message spends its first megabytes window-parked,
+ // paying a round trip per doubling (stream.go:229) before it reaches full
+ // rate. On a link that is also carrying token streams for other workers,
+ // that ramp is pure added latency on the bulk transfer for no benefit.
+ peerLinkInitialWindow = 4 * 1024 * 1024
+
+ // peerLinkMaxWindow is the ceiling the auto-tuner may grow a stream to,
+ // raised from yamux's 16 MiB default to cover the bandwidth-delay product
+ // of a fast cross-zone link (roughly 31 MiB at 10 Gbps and 25 ms).
+ //
+ // The window is a cap on data received but not yet read, so the worst case
+ // a peer can make this replica buffer is MaxIncomingStreams times this.
+ // At yamux's default MaxIncomingStreams of 1000 that ceiling goes from
+ // about 15.6 GiB to about 31 GiB per peer session, which is the figure to
+ // size a replica against; it is why MaxIncomingStreams is left at the
+ // default rather than raised alongside the window. Both are ceilings on
+ // unread data and not allocations: yamux grows a stream's receive buffer
+ // as data arrives.
+ peerLinkMaxWindow = 32 * 1024 * 1024
+)
+
+// PeerLinkConfig returns the yamux configuration for a replica-to-replica link.
+//
+// Exported because BOTH ENDS need it and only one of them lives here. A yamux
+// receive window is advertised by the side that RECEIVES, so a link configured
+// on the dialler alone is tuned in exactly one direction: bytes travelling from
+// the accepting replica back to the dialler get these windows, and bytes
+// travelling the other way get yamux's defaults. The other way is the one that
+// carries a relayed model artifact to the replica that owns the worker's
+// tunnel, which is the largest thing this link ever moves.
+//
+// A fresh config per call, never a shared one: yamux keeps the pointer for the
+// life of the session, and two sessions sharing one struct would share whatever
+// a future field on it comes to mean.
+func PeerLinkConfig() *yamux.Config {
+ cfg := yamux.DefaultConfig()
+ cfg.InitialStreamWindowSize = peerLinkInitialWindow
+ cfg.MaxStreamWindowSize = peerLinkMaxWindow
+ return cfg
+}
+
+// PeerPool dials peer replicas and keeps one multiplexed session per peer.
+//
+// A peer link carries traffic for every worker that peer owns, so it is pooled
+// rather than dialled per request: a dial per relayed request would add a
+// WebSocket handshake to every inference.
+//
+// The pool needs no knowledge of yamux error shapes to keep its cache honest.
+// The two conditions worth reacting to arrive as OpenStream failures and are
+// handled by the same retry: a peer that shut down gracefully hands its
+// session ErrRemoteGoAway and closes it, and a session whose transport died
+// hands out its shutdown error. Conditions scoped to a single stream, such as
+// a peer resetting one request, never reach the pool at all, which is right:
+// dropping the session over one reset request would tear down every other
+// worker's traffic on that link.
+type PeerPool struct {
+ selfID string
+ token string
+ reg *Registry
+
+ dialer *websocket.Dialer
+
+ mu sync.Mutex
+ links map[string]*peerLink
+ closed bool
+}
+
+// peerLink is the cached session for one peer, plus the lock that serialises
+// dialling it. The lock is per-peer so a slow or hanging dial to one peer does
+// not hold up opens to any other.
+type peerLink struct {
+ mu sync.Mutex
+ sess *yamux.Session
+}
+
+// NewPeerPool returns a pool that dials peers as selfID, authenticating with
+// the deployment's cluster token.
+func NewPeerPool(selfID, token string, reg *Registry) *PeerPool {
+ return &PeerPool{
+ selfID: selfID,
+ token: token,
+ reg: reg,
+ dialer: &websocket.Dialer{
+ HandshakeTimeout: peerLinkHandshakeTimeout,
+ // No Proxy: a peer link is replica-to-replica inside one
+ // deployment, and honouring HTTP_PROXY would route it through
+ // whatever egress proxy the environment happens to name.
+ },
+ links: map[string]*peerLink{},
+ }
+}
+
+// Open returns a stream to peerID, dialling and caching the session on first
+// use.
+//
+// The errors are three distinct conditions and callers act differently on
+// them: ErrInstanceNotFound means the peer is not part of this deployment,
+// ErrPeerUnreachable means it is but will not answer, and ErrPoolClosed means
+// this process is shutting down. Only the first is node absence.
+func (p *PeerPool) Open(ctx context.Context, peerID string) (net.Conn, error) {
+ l, err := p.link(peerID)
+ if err != nil {
+ return nil, err
+ }
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if l.sess != nil {
+ st, err := l.sess.OpenStream(ctx)
+ if err == nil {
+ return st, nil
+ }
+ // A caller whose own budget expired must not cost every other worker
+ // its link: the session is fine, this request is not.
+ if ctxErr := callerRanOut(ctx); ctxErr != nil {
+ return nil, ctxErr
+ }
+ // A session that died between calls is the common case, not an
+ // exception, so this is a debug line and not a warning.
+ xlog.Debug("cluster peer link session unusable, re-dialling", "peer", peerID, "error", err)
+ _ = l.sess.Close()
+ l.sess = nil
+ }
+
+ sess, err := p.dial(ctx, peerID)
+ if err != nil {
+ // Same rule as above, on the path that has no cached session to
+ // protect: a dial that ran out of the caller's time says nothing about
+ // the peer, which may be listening and perfectly healthy. Blaming it
+ // would let one impatient client get a good replica routed around.
+ //
+ // This also swallows a genuine ErrInstanceNotFound when the budget
+ // happened to expire at the same moment, which is the safe direction:
+ // a timeout must never be able to manufacture absence.
+ if ctxErr := callerRanOut(ctx); ctxErr != nil {
+ return nil, ctxErr
+ }
+ return nil, err
+ }
+
+ st, err := sess.OpenStream(ctx)
+ if err != nil {
+ // The peer answered and completed a handshake but will not carry a
+ // stream, which is a transport condition and never absence.
+ _ = sess.Close()
+ if ctxErr := callerRanOut(ctx); ctxErr != nil {
+ return nil, ctxErr
+ }
+ return nil, unreachablePeer(peerID, err)
+ }
+
+ l.sess = sess
+ return st, nil
+}
+
+// callerRanOut reports whether the CALLER's budget is what ended an attempt,
+// and is the one place that question is answered.
+//
+// ctx.Err() alone is not that question, and the difference is a real
+// misclassification rather than a nicety. A dial carries the caller's deadline
+// down to the socket, so when the budget runs out the socket's own timer fires
+// and the error travels back up through the WebSocket handshake and the
+// multiplexer. The context's cancellation is a SEPARATE timer whose func has to
+// be run by the scheduler before ctx.Err() stops returning nil, and nothing
+// orders the two. Under contention the socket's error can be back here first,
+// ctx.Err() reads nil, and a peer that is listening and healthy is reported as
+// ErrPeerUnreachable to a caller that simply ran out of time.
+//
+// That is the exact confusion this package refuses everywhere else: an
+// unreachable peer is a fact about the peer that a caller may act on, and an
+// expired deadline is a fact about the caller that it may not. The wall clock
+// settles it without waiting for a goroutine, because the deadline is the same
+// instant the socket compared itself against: if the socket's timer fired, this
+// comparison is past it too.
+//
+// A context with no deadline falls through to ctx.Err(), which is the whole
+// answer for cancellation: a Canceled context has already had its error set by
+// the caller of cancel, with no timer in between.
+func callerRanOut(ctx context.Context) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ // Not time.Now().After: a failure at exactly the deadline is the caller's
+ // too, and the ambiguous instant is resolved towards never blaming a peer.
+ if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
+ return context.DeadlineExceeded
+ }
+ return nil
+}
+
+// link returns the per-peer entry, creating it on first use.
+//
+// Entries are never pruned: a peer id opened once keeps its entry, and any
+// session cached on it, until Close. The cost is not the map entry. A peer that
+// has left the deployment but is still listening keeps a live WebSocket and the
+// two yamux loop goroutines behind it for as long as this process runs; a peer
+// that is genuinely gone is reclaimed by the 30s keepalive default, so the real
+// exposure is narrow. There is no Forget: the membership sweep DELETES departed
+// replicas but reports only how many, so which ones they were would have to be
+// surfaced before anything could be plumbed here. Until it is, an entry for a
+// departed peer outlives it and only the keepalive reclaims what it holds.
+func (p *PeerPool) link(peerID string) (*peerLink, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if p.closed {
+ return nil, ErrPoolClosed
+ }
+ l, ok := p.links[peerID]
+ if !ok {
+ l = &peerLink{}
+ p.links[peerID] = l
+ }
+ return l, nil
+}
+
+// dial resolves the peer's advertised address and brings up one yamux client
+// session over an authenticated WebSocket.
+//
+// A registry miss is returned unchanged so ErrInstanceNotFound reaches the
+// caller; everything after it is wrapped as unreachable.
+//
+// The address is only read here, so a peer that re-registers on a new address
+// while its current session is still alive keeps being reached over that
+// session until it dies. That is deliberate: an address change without a
+// session break means the peer is still answering on the old one, and dropping
+// a working link to chase a registry write would interrupt live requests for
+// nothing. A replica that actually moved breaks its sessions in the process,
+// and the re-dial above picks the new address up on the next Open.
+func (p *PeerPool) dial(ctx context.Context, peerID string) (*yamux.Session, error) {
+ inst, err := p.reg.Get(ctx, peerID)
+ if err != nil {
+ return nil, err
+ }
+ if inst.AdvertisedAddr == "" {
+ // A registered replica with no address is reachable by nobody. It is
+ // present, so this is not absence.
+ return nil, unreachablePeer(peerID, errors.New("peer has no advertised address"))
+ }
+
+ endpoint := url.URL{
+ // Plain ws: replica-to-replica TLS is not part of this phase, and the
+ // link is authenticated by the cluster token rather than by transport.
+ Scheme: "ws",
+ Host: inst.AdvertisedAddr,
+ Path: PeerPath,
+ RawQuery: url.Values{"id": []string{p.selfID}}.Encode(),
+ }
+ header := http.Header{}
+ header.Set("Authorization", "Bearer "+p.token)
+
+ ws, resp, err := p.dialer.DialContext(ctx, endpoint.String(), header)
+ if resp != nil && resp.Body != nil {
+ // gorilla hands back the failed handshake's response so a caller can
+ // read the status; nothing here needs the body, but it has to be
+ // drained or the connection is not returned to the transport.
+ _ = resp.Body.Close()
+ }
+ if err != nil {
+ return nil, unreachablePeer(peerID, err)
+ }
+
+ // Client side of the mux: the dialling replica owns the odd stream IDs,
+ // matching the yamux.Server the peer handler puts on its end.
+ sess, err := yamux.Client(WebsocketConn(ws), PeerLinkConfig(), nil)
+ if err != nil {
+ _ = ws.Close()
+ return nil, unreachablePeer(peerID, err)
+ }
+
+ // Close raced this dial. Handing the session back would leak it, since
+ // Close has already walked the map.
+ p.mu.Lock()
+ closed := p.closed
+ p.mu.Unlock()
+ if closed {
+ _ = sess.Close()
+ return nil, ErrPoolClosed
+ }
+
+ xlog.Debug("cluster peer link dialled", "peer", peerID, "addr", inst.AdvertisedAddr)
+ return sess, nil
+}
+
+// Close closes every cached session. It is safe to call twice, and an Open
+// after it reports ErrPoolClosed rather than anything a caller could read as
+// node absence.
+func (p *PeerPool) Close() {
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ return
+ }
+ p.closed = true
+ links := p.links
+ p.links = nil
+ p.mu.Unlock()
+
+ // Each session is closed under its own peer lock rather than under p.mu,
+ // so closing the pool cannot deadlock against an Open that is mid-dial and
+ // about to take p.mu to re-check p.closed.
+ for _, l := range links {
+ l.mu.Lock()
+ if l.sess != nil {
+ _ = l.sess.Close()
+ l.sess = nil
+ }
+ l.mu.Unlock()
+ }
+}
diff --git a/core/services/cluster/peerlink_internal_test.go b/core/services/cluster/peerlink_internal_test.go
new file mode 100644
index 000000000000..e61a4a4bd2de
--- /dev/null
+++ b/core/services/cluster/peerlink_internal_test.go
@@ -0,0 +1,41 @@
+package cluster
+
+// These specs are in-package because the property they pin is a property of
+// the error TYPE, not of any call site. Asserting it only from outside would
+// re-check the paths peerlink_test.go already drives, which leaves the type
+// free to start leaking its cause the moment a new call site is added.
+//
+// The other direction of the rule (a node absent from the registry is not
+// merely unreachable) is driven end to end by peerlink_test.go through the
+// real Registry, so it is not restated here.
+
+import (
+ "errors"
+ "fmt"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Peer unreachability is not node absence", func() {
+ It("stays a transport error even when its cause is an absence error", func() {
+ // The dial path resolves the peer's address through the registry, so
+ // an ErrInstanceNotFound is genuinely reachable as a dial cause (a row
+ // deleted between the lookup and a retry, say). If the type let that
+ // through, a peer that merely would not answer would read as an absent
+ // node, and a replica acting on absence evicts healthy workers.
+ err := unreachablePeer("peer-1", fmt.Errorf("resolving: %w", ErrInstanceNotFound))
+
+ Expect(errors.Is(err, ErrPeerUnreachable)).To(BeTrue())
+ Expect(errors.Is(err, ErrInstanceNotFound)).To(BeFalse(),
+ "the unreachable error must not unwrap to its cause, or absence leaks through it")
+ })
+
+ It("keeps the cause legible in its message", func() {
+ // Withholding the cause from errors.Is must not withhold it from a
+ // human reading a log line.
+ err := unreachablePeer("peer-1", errors.New("connection refused"))
+ Expect(err.Error()).To(ContainSubstring("peer-1"))
+ Expect(err.Error()).To(ContainSubstring("connection refused"))
+ })
+})
diff --git a/core/services/cluster/peerlink_test.go b/core/services/cluster/peerlink_test.go
new file mode 100644
index 000000000000..d4406e5e167d
--- /dev/null
+++ b/core/services/cluster/peerlink_test.go
@@ -0,0 +1,346 @@
+package cluster_test
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "time"
+
+ clusterep "github.com/mudler/LocalAI/core/http/endpoints/cluster"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+// servePeerRoute mounts the peer handler on the route both sides agree on.
+//
+// It deliberately does not call routes.RegisterClusterRoutes: that registrar
+// lives in core/http/routes, which imports half the server, and these specs are
+// about the handler and the dialler rather than about the route table. The path
+// comes from the same constant the registrar uses, so the two cannot drift.
+func servePeerRoute(e *echo.Echo, token string, onPeer func(string, *yamux.Session)) {
+ e.GET(cluster.PeerPath, clusterep.PeerHandler(token, onPeer))
+}
+
+// deadlinePassed is a context whose deadline has elapsed and whose
+// cancellation has not been delivered, which is the state a caller is in for
+// the moment between the two timers that fire at its deadline. Only Deadline is
+// overridden: the embedded context supplies a nil Done and a nil Err, which is
+// what a context in that window reports.
+type deadlinePassed struct{ context.Context }
+
+func (deadlinePassed) Deadline() (time.Time, bool) {
+ return time.Now().Add(-time.Millisecond), true
+}
+
+var _ = Describe("Peer pool", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ pool *cluster.PeerPool
+ srv *httptest.Server
+ accepted chan *yamux.Session
+ ctx context.Context
+ )
+
+ // startPeer stands up a real peer server and registers it under peerID.
+ startPeer := func(peerID string) *httptest.Server {
+ e := echo.New()
+ servePeerRoute(e, "peer-token", func(_ string, s *yamux.Session) {
+ accepted <- s
+ })
+ ts := httptest.NewServer(e)
+ addr := strings.TrimPrefix(ts.URL, "http://")
+ Expect(reg.Register(ctx, peerID, addr, "test")).To(Succeed())
+ return ts
+ }
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ accepted = make(chan *yamux.Session, 4)
+ pool = cluster.NewPeerPool("self", "peer-token", reg)
+ DeferCleanup(pool.Close)
+ srv = startPeer("peer-1")
+ DeferCleanup(srv.Close)
+ })
+
+ It("opens a working stream to a live peer", func() {
+ st, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = st.Close() })
+
+ var serverSess *yamux.Session
+ Eventually(accepted, "10s").Should(Receive(&serverSess))
+
+ go func() {
+ defer GinkgoRecover()
+ _, _ = st.Write([]byte("ping"))
+ }()
+
+ got := make(chan []byte, 1)
+ go func() {
+ defer GinkgoRecover()
+ in, e := serverSess.AcceptStream()
+ if e != nil {
+ return
+ }
+ buf := make([]byte, 4)
+ if _, e := io.ReadFull(in, buf); e == nil {
+ got <- buf
+ }
+ }()
+ Eventually(got, "10s").Should(Receive(Equal([]byte("ping"))))
+ })
+
+ It("identifies itself to the peer by its own instance id", func() {
+ // The peer records which replica is on the far end of the link, so a
+ // pool that sent the peer's id (or nothing) would leave every inbound
+ // link anonymous and indistinguishable from every other.
+ ids := make(chan string, 1)
+ e := echo.New()
+ servePeerRoute(e, "peer-token", func(id string, _ *yamux.Session) { ids <- id })
+ ts := httptest.NewServer(e)
+ DeferCleanup(ts.Close)
+ Expect(reg.Register(ctx, "peer-named", strings.TrimPrefix(ts.URL, "http://"), "test")).To(Succeed())
+
+ st, err := pool.Open(ctx, "peer-named")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = st.Close() })
+ Eventually(ids, "10s").Should(Receive(Equal("self")))
+ })
+
+ It("reuses one session across opens rather than dialling per stream", func() {
+ a, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = a.Close() })
+ Eventually(accepted, "10s").Should(Receive())
+
+ b, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = b.Close() })
+
+ // A second dial would deliver a second server session. One session
+ // serving both streams is the property under test: peer links are
+ // pooled, not per-stream.
+ Consistently(accepted, "2s", "200ms").ShouldNot(Receive())
+ })
+
+ It("returns ErrPeerUnreachable when the peer is registered but not listening", func() {
+ dead := startPeer("peer-dead")
+ dead.Close()
+
+ _, err := pool.Open(ctx, "peer-dead")
+ Expect(err).To(MatchError(cluster.ErrPeerUnreachable),
+ "a peer that will not answer must be a transport error, never node absence")
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound),
+ "an unreachable peer must never be readable as an absent node; a replica acting on absence evicts healthy workers")
+ })
+
+ It("returns ErrPeerUnreachable when the peer answers but rejects the credentials", func() {
+ // A token mismatch is a live peer refusing the link, not a missing
+ // row. Reporting absence here would evict every worker behind a peer
+ // that was merely rolled out with a stale secret.
+ e := echo.New()
+ servePeerRoute(e, "a-different-token", func(_ string, s *yamux.Session) { accepted <- s })
+ ts := httptest.NewServer(e)
+ DeferCleanup(ts.Close)
+ Expect(reg.Register(ctx, "peer-strict", strings.TrimPrefix(ts.URL, "http://"), "test")).To(Succeed())
+
+ _, err := pool.Open(ctx, "peer-strict")
+ Expect(err).To(MatchError(cluster.ErrPeerUnreachable))
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ })
+
+ It("returns ErrInstanceNotFound when the peer is not in the registry", func() {
+ _, err := pool.Open(ctx, "never-registered")
+ Expect(err).To(MatchError(cluster.ErrInstanceNotFound))
+ Expect(err).ToNot(MatchError(cluster.ErrPeerUnreachable),
+ "a node that was never registered is absent, not merely unreachable")
+ })
+
+ It("re-dials after the cached session dies", func() {
+ first, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(first.Close()).To(Succeed())
+
+ var serverSess *yamux.Session
+ Eventually(accepted, "10s").Should(Receive(&serverSess))
+ Expect(serverSess.Close()).To(Succeed())
+ srv.Close()
+
+ // A replacement peer comes back on a new address under the same id,
+ // which is what a restarted replica looks like.
+ replacement := startPeer("peer-1")
+ DeferCleanup(replacement.Close)
+
+ Eventually(func() error {
+ st, e := pool.Open(ctx, "peer-1")
+ if e == nil {
+ _ = st.Close()
+ }
+ return e
+ }, "15s", "500ms").Should(Succeed())
+
+ // The replacement's own session proves the pool re-dialled the address
+ // it re-read from the registry rather than resurrecting the dead one.
+ Eventually(accepted, "10s").Should(Receive())
+ })
+
+ It("does not drop the pooled session when a single stream is reset by the peer", func() {
+ // A peer-initiated stream reset is scoped to one request. Dropping the
+ // session on it would tear down every other worker's traffic on the
+ // same link, so the pool must keep the session and hand out a fresh
+ // stream on it.
+ st, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+
+ var serverSess *yamux.Session
+ Eventually(accepted, "10s").Should(Receive(&serverSess))
+
+ go func() {
+ defer GinkgoRecover()
+ _, _ = st.Write([]byte("x"))
+ }()
+ var inbound *yamux.Stream
+ Eventually(func() error {
+ s, e := serverSess.AcceptStream()
+ inbound = s
+ return e
+ }, "10s").Should(Succeed())
+ // Reset, not a graceful close: this is the *StreamError{Remote:true}
+ // the far end sends when it abandons a request.
+ Expect(inbound.Reset()).To(Succeed())
+ Eventually(func() error {
+ _, e := st.Write([]byte("y"))
+ return e
+ }, "10s", "100ms").Should(HaveOccurred())
+
+ next, err := pool.Open(ctx, "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = next.Close() })
+ Consistently(accepted, "2s", "200ms").ShouldNot(Receive(),
+ "a reset stream must not cost the whole peer link")
+ })
+
+ It("blames the caller's deadline, not the peer, when a dial runs out of time", func() {
+ // A listener that completes the TCP connection and then says nothing,
+ // which is what a peer under load or behind a wedged proxy looks like.
+ // The peer is not unreachable; the caller is impatient. Reporting
+ // ErrPeerUnreachable here would make an impatient client enough to get
+ // a healthy replica routed around.
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = ln.Close() })
+ // The accept loop owns the connections it holds and closes them when
+ // the listener goes away, so nothing is shared with the spec goroutine.
+ go func() {
+ defer GinkgoRecover()
+ var held []net.Conn
+ defer func() {
+ for _, c := range held {
+ _ = c.Close()
+ }
+ }()
+ for {
+ c, e := ln.Accept()
+ if e != nil {
+ return
+ }
+ // Hold the connection open without ever answering the upgrade.
+ held = append(held, c)
+ }
+ }()
+ Expect(reg.Register(ctx, "peer-silent", ln.Addr().String(), "test")).To(Succeed())
+
+ deadlined, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
+ DeferCleanup(cancel)
+ _, err = pool.Open(deadlined, "peer-silent")
+ Expect(err).To(MatchError(context.DeadlineExceeded))
+ Expect(err).ToNot(MatchError(cluster.ErrPeerUnreachable),
+ "the caller ran out of time; the peer never got a verdict")
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ })
+
+ It("blames the caller's deadline even when its cancellation has not landed yet", func() {
+ // The same rule as the spec above, at the instant that makes it hard.
+ //
+ // A dial carries the caller's deadline down to the socket, so the
+ // socket's timer and the context's cancellation timer fire at the same
+ // moment and nothing orders them. The socket's error can be back in
+ // Open before the scheduler has run the context's cancel func, and in
+ // that window ctx.Err() is nil while the caller's budget is
+ // unambiguously spent. Reading only ctx.Err() there reports a peer that
+ // is listening and healthy as unreachable.
+ //
+ // That window is real: this spec's sibling above reproduces it under
+ // `-race` about three runs in seven, which is exactly often enough to
+ // be dismissed as noise. Here it is made deterministic instead, by
+ // handing Open a context in precisely that state: deadline passed,
+ // cancellation not delivered. Nothing is faked about the dial, which
+ // runs for real against an address nothing is listening on.
+ refused, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ addr := refused.Addr().String()
+ Expect(refused.Close()).To(Succeed())
+ Expect(reg.Register(ctx, "peer-refusing", addr, "test")).To(Succeed())
+
+ _, err = pool.Open(deadlinePassed{ctx}, "peer-refusing")
+ Expect(err).To(MatchError(context.DeadlineExceeded))
+ Expect(err).ToNot(MatchError(cluster.ErrPeerUnreachable),
+ "the caller's budget was spent before the dial was made; blaming the peer for it is how an impatient client gets a healthy replica routed around")
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ })
+
+ It("dials once when many callers open the same peer at the same time", func() {
+ // Without a per-peer lock held across the dial, every concurrent
+ // caller races to dial and all but one of the resulting sessions is
+ // dropped on the floor still holding a live WebSocket.
+ const callers = 16
+ streams := make(chan net.Conn, callers)
+ var wg sync.WaitGroup
+ for range callers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ defer GinkgoRecover()
+ st, err := pool.Open(context.Background(), "peer-1")
+ Expect(err).ToNot(HaveOccurred())
+ streams <- st
+ }()
+ }
+ wg.Wait()
+ close(streams)
+
+ count := 0
+ for st := range streams {
+ count++
+ DeferCleanup(func(c net.Conn) { _ = c.Close() }, st)
+ }
+ Expect(count).To(Equal(callers))
+
+ Eventually(accepted, "10s").Should(Receive())
+ Consistently(accepted, "2s", "200ms").ShouldNot(Receive(),
+ "concurrent opens must share one dial, not race to dial per caller")
+ })
+
+ It("refuses to open after Close and is safe to close twice", func() {
+ pool.Close()
+ pool.Close()
+
+ _, err := pool.Open(ctx, "peer-1")
+ Expect(err).To(HaveOccurred())
+ Expect(err).ToNot(MatchError(cluster.ErrInstanceNotFound),
+ "a locally closed pool says nothing about whether the node exists")
+ })
+})
diff --git a/core/services/cluster/presence.go b/core/services/cluster/presence.go
new file mode 100644
index 000000000000..118136b36593
--- /dev/null
+++ b/core/services/cluster/presence.go
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+// Presence is what this package can say about a worker's tunnel.
+//
+// Four values and not a boolean, because the conditions underneath are four and
+// only ONE of them is a verdict a caller may act on. Collapsing any pair is the
+// defect this phase exists to prevent: absence is what makes the scheduler stop
+// placing work, reap node rows and evict models, and one of those paths runs
+// during inference, so a worker misreported as absent loses the models it is
+// serving at that moment.
+//
+// What this type does NOT cover is the two conditions that live on the dialing
+// path: a replica that would not answer, and the worker's own reply (including
+// its "that backend is not there"). Those are IsWorkerAnswer and ErrNoRoute in
+// dialer.go, and they are deliberately not spelled again here.
+type Presence uint8
+
+const (
+ // PresenceUnknown reports that no connection row exists for this node at
+ // all. This package cannot tell a worker that has never dialled from one
+ // whose departure aged out of retention, and it must not guess: the first
+ // is a worker still starting up and the second is one long gone.
+ //
+ // It is the zero value on purpose, so a Presence returned alongside an
+ // error is the value nobody may act on rather than one that reads as a
+ // verdict.
+ PresenceUnknown Presence = iota
+ // PresenceConnected reports that a LIVE replica holds this worker's tunnel.
+ PresenceConnected
+ // PresenceReconnecting reports that no live replica holds the tunnel and
+ // the grace has not run out: either the departure is recent, or the owning
+ // replica died and no departure has been stamped yet. A retry, never a
+ // verdict; nobody may act on it.
+ PresenceReconnecting
+ // PresenceGone reports that no live replica holds the tunnel and the
+ // departure is older than the grace. This is the ONLY value a caller may
+ // read as absence.
+ PresenceGone
+)
+
+// String names the value for a log line. The default case names the number
+// rather than falling through to a real value: a caller reading "gone" out of a
+// log for a value that does not exist would be reading the one answer it is
+// allowed to reap on.
+func (p Presence) String() string {
+ switch p {
+ case PresenceUnknown:
+ return "unknown"
+ case PresenceConnected:
+ return "connected"
+ case PresenceReconnecting:
+ return "reconnecting"
+ case PresenceGone:
+ return "gone"
+ default:
+ return fmt.Sprintf("presence(%d)", uint8(p))
+ }
+}
+
+// presenceQuery answers all three questions about one node in one statement:
+// does a row exist, does a LIVE replica hold it, and is its departure older
+// than the grace.
+//
+// One statement rather than a read followed by an instance lookup, for the
+// reason Owner is one statement: between two reads the owning replica can die,
+// and the answer would then be assembled from two different snapshots of the
+// cluster. Phase 2 shipped exactly that as a dialer relaying into a corpse for
+// a whole liveness window.
+//
+// HELD-NESS IS ASKED FIRST, and the departure only refines it. Every writer in
+// this binary clears disconnected_at in the same statement that writes the
+// owner, but that is a property of these writers rather than of the table: a
+// replica running a binary from before the column existed re-claims WITHOUT
+// clearing the stamp, so during a rolling upgrade a HELD row can carry a
+// departure older than any grace. Reading the stamp first, or treating a
+// non-null stamp as evidence of absence on its own, reports a worker that is
+// connected right now as gone. That is why `departed` carries the negation of
+// connectionIsHeld rather than standing on the timestamp alone.
+//
+// Both windows are computed by the DATABASE. They are compared across replicas,
+// so a Go-side cutoff would make the effective window depend on each replica's
+// clock skew, and replicas disagreeing about whether a worker is gone is the
+// flapping this branch exists to remove. No behavioural spec can see the
+// difference (the test container shares the host clock), which is why the
+// statement shape is pinned instead.
+//
+// The predicates are the package's own, not copies. Two spellings of "live" or
+// of "held" drift, and the drift here reads as a worker that one query calls
+// connected and another calls gone.
+//
+// The bind order is the order the placeholders appear in the text: the grace,
+// then the liveness window, then the node id.
+const presenceQuery = `
+SELECT
+ (` + connectionIsHeld + ` AND instances.id IS NOT NULL) AS held,
+ (NOT (` + connectionIsHeld + `)
+ AND node_connections.disconnected_at IS NOT NULL
+ AND node_connections.disconnected_at < now() - make_interval(secs => ?)) AS departed
+FROM node_connections
+LEFT JOIN instances
+ ON instances.id = node_connections.owner_instance_id
+ AND ` + instanceIsLive + `
+WHERE node_connections.node_id = ?`
+
+// Presence reports what this deployment can say about nodeID's tunnel, with
+// grace as the window a departure has to outlive before it becomes a verdict.
+//
+// The grace is a parameter rather than a constant because it is an operator's
+// trade (see DistributedConfig.WorkerReconnectGrace), where the liveness window
+// inside the query is not: that one has to be the window the membership loop
+// sweeps with, or a reader would keep trusting an owner the sweeper has already
+// declared dead.
+//
+// A held row whose owner is no longer live is PresenceReconnecting and not
+// PresenceGone, deliberately. Nothing has stamped a departure on it yet, so
+// there is no age to compare and the grace clock has not started; the
+// membership sweep is what starts it. A replica dying must not condemn every
+// worker it held.
+func (r *Registry) Presence(ctx context.Context, nodeID string, grace time.Duration) (Presence, error) {
+ // Refused rather than attempted, for the reason Owner refuses: now() and
+ // make_interval are PostgreSQL, so on the single-binary SQLite path this
+ // would fail with "no such function: now", which reads as a missing
+ // migration. The refusal carries PresenceUnknown, the one value nobody may
+ // act on: a deployment with no cluster has no answer about a worker's
+ // tunnel, and reporting PresenceGone would license a reap.
+ if !isPostgres(r.db) {
+ return PresenceUnknown, fmt.Errorf("reading presence of node %q: connection ownership requires PostgreSQL, this deployment runs on %q", nodeID, r.db.Dialector.Name())
+ }
+ var row struct {
+ Held bool
+ Departed bool
+ }
+ // gorm's Scan leaves the destination zero-valued when nothing matched and
+ // reports no error, so "no row" and "a row whose booleans are both false"
+ // are told apart by RowsAffected and by nothing else. Scan does not produce
+ // gorm.ErrRecordNotFound, so matching on that would silently turn every
+ // missing row into PresenceReconnecting.
+ res := r.db.WithContext(ctx).Raw(presenceQuery,
+ grace.Seconds(), InstanceLiveness.Seconds(), nodeID,
+ ).Scan(&row)
+ if res.Error != nil {
+ return PresenceUnknown, fmt.Errorf("reading presence of node %q: %w", nodeID, res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return PresenceUnknown, nil
+ }
+ switch {
+ case row.Held:
+ // First, and redundantly so: the query already excludes held rows from
+ // `departed` (on the RAW column, before the join), so either gate alone
+ // answers Connected for a held row with a live owner, and no
+ // behavioural spec can tell which one is doing the work.
+ //
+ // The redundancy is PARTIAL, not a second complete gate. With the SQL
+ // gate removed and the owner DEAD, `held` is false and `departed` is
+ // true, so this ordering yields Gone where Reconnecting is correct.
+ // That is why the SQL gate carries its own assertion in the
+ // statement-shape spec: removing it is caught there, not here.
+ return PresenceConnected, nil
+ case row.Departed:
+ return PresenceGone, nil
+ default:
+ return PresenceReconnecting, nil
+ }
+}
diff --git a/core/services/cluster/presence_test.go b/core/services/cluster/presence_test.go
new file mode 100644
index 000000000000..eeb66ca06935
--- /dev/null
+++ b/core/services/cluster/presence_test.go
@@ -0,0 +1,306 @@
+// SPDX-License-Identifier: MIT
+
+package cluster_test
+
+import (
+ "context"
+ "math"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+// age pushes a departure or a heartbeat into the past ON THE DATABASE CLOCK.
+// Writing a Go-side time.Now().Add(-d) would age the row against this process's
+// clock and then compare it against the database's, which is precisely the skew
+// every window in this package is written to be immune to; a helper that did
+// that would make the specs agree with an implementation that has the bug.
+func age(ctx context.Context, db *gorm.DB, table, column, keyColumn, key string, by time.Duration) {
+ GinkgoHelper()
+ Expect(db.WithContext(ctx).Exec(
+ `UPDATE `+table+` SET `+column+` = now() - make_interval(secs => ?) WHERE `+keyColumn+` = ?`,
+ by.Seconds(), key).Error).To(Succeed())
+}
+
+var _ = Describe("Presence", func() {
+ var (
+ ctx context.Context
+ db *gorm.DB
+ reg *cluster.Registry
+ )
+
+ // The grace every spec measures against, so the ageing below can sit just
+ // over the edge of it rather than an order of magnitude past it: a window
+ // only ever aged ten times its own width is a window nothing pins, and
+ // widening it stays green.
+ const grace = 60 * time.Second
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
+ })
+
+ It("reports unknown for a node with no connection row", func() {
+ // Not "gone". This package cannot tell a worker that has never dialled
+ // from one whose departure aged out of retention, and a caller that
+ // reaps on absence would reap a worker that is still starting up.
+ p, err := reg.Presence(ctx, "never-seen", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceUnknown))
+ })
+
+ It("reports connected while a live replica holds the tunnel", func() {
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceConnected))
+ })
+
+ It("reports connected for a held row that still carries an old departure stamp", func() {
+ // The rolling-upgrade case, and the one thing this read must not get
+ // wrong. Every writer in THIS binary clears disconnected_at in the same
+ // statement that writes the owner, but a replica running a binary from
+ // before the column existed re-claims WITHOUT clearing it, so a held row
+ // can carry a departure older than any grace. Held-ness is the question
+ // and the stamp only refines it; a read that consults the stamp first,
+ // or treats a non-null stamp as evidence of absence, reports a worker
+ // that is connected right now as gone, for the whole upgrade.
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age(ctx, db, "node_connections", "disconnected_at", "node_id", "node-1", 10*grace)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceConnected),
+ "a row whose connection is HELD is present whatever its departure stamp says")
+ })
+
+ It("reports reconnecting for a held row whose owner is dead and whose stamp is stale", func() {
+ // The full mixed-version rolling-upgrade state, which no other spec
+ // constructs: the row is HELD, the owner is NOT live, and the stamp is
+ // older than the grace. It is reachable exactly once, during an upgrade
+ // where a pre-column replica re-claimed without clearing the stamp and
+ // then died before the sweep reached it.
+ //
+ // Reconnecting is the only defensible answer. The worker is re-dialling
+ // the load balancer right now, and the stamp on this row records a
+ // departure from a DIFFERENT, earlier session, so its age says nothing
+ // about the current one; the grace clock starts when the sweep stamps a
+ // departure for THIS session, and it has not run yet.
+ //
+ // Both of the ways this task can be got wrong land on a different
+ // answer here, which is why the state is worth constructing rather than
+ // reasoning about. Reading the stamp first gives Gone. Reading
+ // held-ness without the liveness join gives Connected.
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age(ctx, db, "node_connections", "disconnected_at", "node_id", "node-1", 10*grace)
+ age(ctx, db, "instances", "last_seen", "id", "inst-a", cluster.InstanceLiveness+5*time.Second)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceReconnecting),
+ "a held row with a dead owner is a retry; its stale stamp belongs to an earlier session and must not become a verdict")
+ })
+
+ It("reports reconnecting immediately after the tunnel is released", func() {
+ epoch, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "node-1", "inst-a", epoch)).To(Succeed())
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceReconnecting),
+ "a departure inside the grace is a retry, not a verdict; nobody may act on it")
+ })
+
+ It("reports gone once the departure is older than the grace", func() {
+ epoch, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "node-1", "inst-a", epoch)).To(Succeed())
+ // One second past the edge, deliberately. Ageing by ten graces would
+ // leave a widened window green, and a window nothing pins is the defect
+ // class this spec exists for.
+ age(ctx, db, "node_connections", "disconnected_at", "node_id", "node-1", grace+time.Second)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceGone))
+ })
+
+ It("still reports reconnecting one second inside the grace", func() {
+ // The other edge. Without it, a read that treats any departure as gone
+ // passes the spec above and condemns every worker re-homing normally.
+ epoch, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reg.Release(ctx, "node-1", "inst-a", epoch)).To(Succeed())
+ age(ctx, db, "node_connections", "disconnected_at", "node_id", "node-1", grace-time.Second)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceReconnecting))
+ })
+
+ It("reports reconnecting, not gone, when the OWNING REPLICA dies and the row is still held", func() {
+ // A replica dying must not condemn every worker it held. The row still
+ // names inst-a and inst-a is no longer live, so nothing holds the
+ // tunnel; but no departure has been stamped, so the grace clock has not
+ // started and there is no age to compare. That is a retry.
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age(ctx, db, "instances", "last_seen", "id", "inst-a", cluster.InstanceLiveness+5*time.Second)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceReconnecting))
+ })
+
+ It("reports gone after the membership sweep has stamped the departure and the grace has passed", func() {
+ // The full path a dead replica's worker takes: the sweep records the
+ // departure, and only then does the grace start running.
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+ age(ctx, db, "instances", "last_seen", "id", "inst-a", cluster.InstanceLiveness+5*time.Second)
+
+ _, connections, err := reg.ReapStale(ctx, "inst-sweeper", cluster.InstanceLiveness)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(connections).To(Equal(int64(1)), "the sweep must have stamped the departure this spec then ages")
+ age(ctx, db, "node_connections", "disconnected_at", "node_id", "node-1", grace+time.Second)
+
+ p, err := reg.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(p).To(Equal(cluster.PresenceGone))
+ })
+
+ It("answers in one joined statement measured on the database clock", func() {
+ _, err := reg.Claim(ctx, "node-1", "inst-a")
+ Expect(err).ToNot(HaveOccurred())
+
+ rec := newSQLRecorder()
+ recording := cluster.NewRegistry(db.Session(&gorm.Session{Logger: rec}))
+ _, err = recording.Presence(ctx, "node-1", grace)
+ Expect(err).ToNot(HaveOccurred())
+
+ sql := strings.ToLower(rec.only())
+ // only() rules out the read-then-look-up shape: between two statements
+ // the owning replica can die, and the answer would then be built from
+ // two different snapshots of the cluster.
+ Expect(sql).To(ContainSubstring("join"))
+ Expect(sql).To(ContainSubstring("instances"))
+ // The departure test is gated on held-ness IN THE SQL, and not only by
+ // the order of the switch that reads these two booleans. Either gate
+ // alone gives the right answer today, so no behavioural spec can tell
+ // which one is doing the work, and a single-layer regression would sit
+ // here unnoticed until the second layer moved too. Held-ness first is
+ // the invariant the whole phase rests on, so both layers are pinned.
+ Expect(strings.Join(strings.Fields(sql), " ")).To(ContainSubstring(
+ "not (node_connections.owner_instance_id <> '') and node_connections.disconnected_at is not null"))
+ // Both windows are computed by the database. A Go-side comparison would
+ // appear here as a bound literal, and replica clock skew would then move
+ // the grace and the liveness window per replica. The test container
+ // shares this host's clock, so no behavioural spec can catch that; the
+ // statement shape is the only place it is visible.
+ Expect(strings.Count(sql, "make_interval")).To(Equal(2),
+ "both the liveness window and the reconnect grace must be computed by the database")
+ Expect(sql).To(ContainSubstring("now()"))
+ Expect(sql).ToNot(MatchRegexp(`disconnected_at\s*<\s*'`),
+ "the grace cutoff must not be a literal timestamp from this process's clock")
+ Expect(sql).ToNot(MatchRegexp(`last_seen\s*>\s*'`),
+ "the liveness cutoff must not be a literal timestamp from this process's clock")
+ })
+
+ Describe("naming the values", func() {
+ It("gives every value a name a log line can carry", func() {
+ Expect(cluster.PresenceUnknown.String()).To(Equal("unknown"))
+ Expect(cluster.PresenceConnected.String()).To(Equal("connected"))
+ Expect(cluster.PresenceReconnecting.String()).To(Equal("reconnecting"))
+ Expect(cluster.PresenceGone.String()).To(Equal("gone"))
+ })
+
+ It("does not name an unknown value after a real one", func() {
+ // A default branch that fell through to "gone" would put the one
+ // value callers may act on into a log line for a value that does
+ // not exist.
+ Expect(cluster.Presence(200).String()).To(ContainSubstring("200"))
+ })
+ })
+
+ Describe("the retention that outlives the grace", func() {
+ // The retention is not pinned to any one grace value on purpose. It is
+ // the purge's window and the grace is a reader's window, and the only
+ // property that matters is that the first always outlasts the second:
+ // a purge that deletes a departure a reader is still measuring against
+ // turns a worker inside its reconnect grace back into a worker that was
+ // never here, and an operator raising the grace past a FIXED retention
+ // would leave a genuinely gone worker reading as unknown forever, so
+ // nothing ever reaps it.
+ DescribeTable("outlasts the grace a reader measures against",
+ func(grace time.Duration) {
+ Expect(cluster.DepartedRetentionFor(grace)).To(BeNumerically(">", grace))
+ },
+ Entry("a grace of one second", time.Second),
+ Entry("the default grace", 60*time.Second),
+ Entry("a grace at the retention floor", cluster.DepartedRetention),
+ Entry("a grace far past the floor", 10*cluster.DepartedRetention),
+ )
+
+ It("does not wrap back to the floor for a grace so large the multiply overflows", func() {
+ // time.Duration is int64 nanoseconds, so five times a grace above
+ // roughly 58 years wraps. An unguarded multiply comes back negative
+ // or small, falls through to the floor, and reinstates exactly the
+ // defect this function exists to remove, silently and only for the
+ // operator who set the largest window.
+ huge := time.Duration(math.MaxInt64)
+ Expect(cluster.DepartedRetentionFor(huge)).To(BeNumerically(">=", huge))
+ // And one just past the threshold, where the wrap is easiest to
+ // miss because the input still looks like an ordinary duration.
+ overflowing := time.Duration(math.MaxInt64/5) + time.Second
+ Expect(cluster.DepartedRetentionFor(overflowing)).To(BeNumerically(">=", overflowing))
+ })
+
+ It("never shortens below the floor for a very small grace", func() {
+ // A tiny grace must not shrink the retention to match: the row is also
+ // what a restarting deployment reads to tell a re-dialling worker from
+ // one it has never seen.
+ Expect(cluster.DepartedRetentionFor(time.Second)).To(Equal(cluster.DepartedRetention))
+ })
+ })
+})
+
+var _ = Describe("Presence on a non-PostgreSQL dialect", func() {
+ var (
+ db *gorm.DB
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ var err error
+ ctx = context.Background()
+ db, err = gorm.Open(sqlite.Open(filepath.Join(GinkgoT().TempDir(), "cluster.db")), &gorm.Config{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ })
+
+ It("refuses to answer, rather than failing as a missing function", func() {
+ p, err := cluster.NewRegistry(db).Presence(ctx, "node-1", time.Minute)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("requires PostgreSQL"))
+ Expect(err.Error()).ToNot(ContainSubstring("no such function"),
+ "a dialect that cannot answer must say so, not surface as a missing migration")
+ Expect(p).To(Equal(cluster.PresenceUnknown),
+ "a refusal must carry the value nobody may act on, never one that reads as absence")
+ })
+})
diff --git a/core/services/cluster/relay.go b/core/services/cluster/relay.go
new file mode 100644
index 000000000000..61594c7abecb
--- /dev/null
+++ b/core/services/cluster/relay.go
@@ -0,0 +1,420 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mudler/xlog"
+)
+
+// The framing every stream on a PEER link opens with.
+//
+// A worker holds one tunnel and it lands on one frontend replica, so every
+// other replica reaches that worker by relaying through the one that holds it.
+// The peer link carries traffic for every worker its far side owns, so a stream
+// on it means nothing until it says which worker it is for; that is this frame.
+//
+// A relayed stream therefore carries TWO request frames back to back: this one,
+// which the owning replica consumes, and the worker tunnel's own (tunnelproto)
+// frame, which crosses untouched and is answered by the worker. A dialler reads
+// one reply from each, in that order.
+//
+// The two vocabularies are deliberately disjoint. "relay-ok" is not "ok", and
+// none of the three refusal codes below is spelled like a tunnel code, so a
+// reader applied to the wrong hop fails with "unrecognised reply" instead of
+// handing back a plausible sentinel that belongs to the other hop. Getting that
+// wrong would report a worker's refusal as the owning replica's, and a caller
+// would retry against the wrong end of the path.
+const (
+ relayReplyAccepted = "relay-ok"
+ relayCodeNotOwner = "relay-not-owner"
+ relayCodeUnavailable = "relay-unavailable"
+ relayCodeBadRequest = "relay-bad-request"
+)
+
+// The refusals the relay hop can send, beyond ErrNotOwner which it shares with
+// the local path.
+//
+// Three conditions, kept apart, for the reason the worker's three are kept
+// apart. ErrNotOwner is a ROUTING fact: the worker may be perfectly healthy on
+// another replica, and the caller should resolve the owner again. This one is
+// INFRASTRUCTURE at the owning replica: the tunnel is held right here and its
+// session will not carry a stream, so a retry is worth something and looking
+// elsewhere is not. ErrRelayRequestInvalid is the CALLER's bug and no retry
+// helps.
+//
+// None of them is, or may ever be built over, an absence error. A refusal is
+// proof that a replica answered, and reporting absence would tell a scheduler
+// that a worker which is connected has gone away.
+var (
+ ErrRelayUnavailable = errors.New("cluster: the owning replica could not open a stream to that worker")
+ ErrRelayRequestInvalid = errors.New("cluster: the owning replica rejected the relay request as malformed")
+)
+
+// WriteRelayRequest names the worker a peer stream is for, and how much time
+// the ORIGINAL client still has.
+//
+// The budget is what makes the relay's own open bound honest. Everything on the
+// far side of this frame is work done on behalf of a caller the relay cannot
+// see, so without it the relay can only fall back to a deployment-wide constant
+// that no operator has the information to set (see relayOpenTimeout). The
+// dialler does have the information, because it holds the caller's context, so
+// it is the one that states it.
+//
+// A zero budget means "not stated" and is written as no budget at all, which is
+// also what an older replica sends. It is NEVER written as the number zero: on
+// the far side that would be indistinguishable from a caller with no time left,
+// and the relay would refuse traffic that is perfectly healthy.
+//
+// An empty node id is refused here rather than on the wire, so a caller with a
+// bug learns at once instead of a round trip later. So is a node id containing
+// the separator, because the split below takes the FIRST one and a node id with
+// a space in it would silently move part of itself into the budget.
+func WriteRelayRequest(w io.Writer, nodeID string, budget time.Duration) error {
+ if nodeID == "" {
+ return fmt.Errorf("writing a relay request: empty node id")
+ }
+ if strings.Contains(nodeID, streamRequestSeparator) {
+ return fmt.Errorf("writing a relay request: node id %q contains a space", nodeID)
+ }
+ if budget <= 0 {
+ return writeFrame(w, nodeID)
+ }
+ // A plain count of milliseconds rather than a duration string: an integer
+ // has one spelling, so two replicas cannot disagree about it the way they
+ // could about a units vocabulary that grew between their versions. Rounded
+ // UP so a sub-millisecond budget stays positive and keeps meaning "almost
+ // none" rather than collapsing into "not stated".
+ millis := (budget + time.Millisecond - 1) / time.Millisecond
+ return writeFrame(w, nodeID+streamRequestSeparator+strconv.FormatInt(int64(millis), 10))
+}
+
+// ReadRelayRequest reads the opening frame of a peer stream. The budget is zero
+// when the dialling replica stated none, which is also what a replica too old
+// to state one sends.
+//
+// A malformed frame is an ordinary error, NOT ErrRelayRequestInvalid: that
+// sentinel is what a relay SENDS to describe a refusal, and producing it here
+// would leave a caller unable to tell "the peer refused my request" from "I
+// could not read the peer's".
+func ReadRelayRequest(r io.Reader) (string, time.Duration, error) {
+ payload, err := readFrame(r)
+ if err != nil {
+ return "", 0, fmt.Errorf("reading a relay request: %w", err)
+ }
+ nodeID, budgetText, stated := strings.Cut(payload, streamRequestSeparator)
+ if nodeID == "" {
+ // An empty payload is a well-formed frame naming no worker. Treating
+ // it as a node called "" would send the caller a routing refusal for a
+ // request no replica can ever serve, so it stays the caller's bug.
+ return "", 0, fmt.Errorf("reading a relay request: empty node id")
+ }
+ if !stated {
+ return nodeID, 0, nil
+ }
+ millis, err := strconv.ParseInt(budgetText, 10, 64)
+ if err != nil {
+ return "", 0, fmt.Errorf("reading a relay request for node %q: budget %q is not a number of milliseconds: %w", nodeID, budgetText, err)
+ }
+ if millis > maxRelayBudgetMillis {
+ // time.Duration is nanoseconds in an int64, so multiplying by
+ // time.Millisecond overflows past about 2.9e11 ms. Overflow here is
+ // bounded in the safe direction (it can only produce a negative or a
+ // small value, and both shorten the declaring peer's OWN open), but a
+ // bound that holds by arithmetic accident is not a bound. Anything past
+ // the ceiling is clamped to it, because a caller claiming to wait
+ // longer than the relay's own backstop gets the backstop either way.
+ millis = maxRelayBudgetMillis
+ }
+ if millis <= 0 {
+ // A caller with nothing left to spend. Reported as such rather than
+ // folded into "not stated", so the relay refuses at once instead of
+ // waiting out a backstop on behalf of a client that has already gone.
+ return nodeID, 0, fmt.Errorf("reading a relay request for node %q: budget %d ms has already expired", nodeID, millis)
+ }
+ return nodeID, time.Duration(millis) * time.Millisecond, nil
+}
+
+// WriteRelayAccepted tells the peer the stream now carries the worker tunnel's
+// own conversation. Everything after this frame belongs to that hop.
+func WriteRelayAccepted(w io.Writer) error { return writeFrame(w, relayReplyAccepted) }
+
+// WriteRelayRefusal reports why a peer's stream will not be relayed. The caller
+// closes the stream afterwards; this only says why.
+//
+// An unclassified reason is sent as bad-request with its text attached, rather
+// than dropped: a refusal a peer cannot read is indistinguishable from a
+// replica that hung up, and those are different problems.
+func WriteRelayRefusal(w io.Writer, reason error) error {
+ code := relayCodeBadRequest
+ switch {
+ case errors.Is(reason, ErrNotOwner):
+ code = relayCodeNotOwner
+ case errors.Is(reason, ErrRelayUnavailable):
+ code = relayCodeUnavailable
+ }
+
+ text := ""
+ if reason != nil {
+ text = strings.Map(func(r rune) rune {
+ // The frame is length-prefixed so a newline would not corrupt it,
+ // but this text lands in a log line on the far side, and a cause
+ // spanning lines is what makes one unsearchable.
+ if r == '\n' || r == '\r' {
+ return ' '
+ }
+ return r
+ }, reason.Error())
+ }
+ frame := replyPrefixRefused + code + streamRequestSeparator + text
+ return writeFrame(w, truncateRunes(frame, maxTunnelFrame))
+}
+
+// ReadRelayReply reads the owning replica's answer to a relay request. nil
+// means the stream is now the worker tunnel's.
+//
+// A failure to READ the reply is returned as itself and never as one of the
+// refusal sentinels: a refusal means a replica answered, a read failure means
+// the peer link broke, and reporting the second as the first would present a
+// dead link as a policy decision.
+func ReadRelayReply(r io.Reader) error {
+ payload, err := readFrame(r)
+ if err != nil {
+ return fmt.Errorf("reading a relay reply: %w", err)
+ }
+ if payload == relayReplyAccepted {
+ return nil
+ }
+ rest, ok := strings.CutPrefix(payload, replyPrefixRefused)
+ if !ok {
+ return fmt.Errorf("reading a relay reply: unrecognised reply %q", payload)
+ }
+ code, text, _ := strings.Cut(rest, streamRequestSeparator)
+ switch code {
+ case relayCodeNotOwner:
+ // ErrNotOwner and nothing else. It is a routing fact, and the sentinels
+ // it must never be confused with are ErrNoConnection (the worker is
+ // connected nowhere) and ErrPeerUnreachable (a replica will not
+ // answer): a caller acts on those by giving up on the worker or by
+ // retrying the peer, and on this one by resolving the owner again.
+ return fmt.Errorf("%w: %s", ErrNotOwner, text)
+ case relayCodeUnavailable:
+ return fmt.Errorf("%w: %s", ErrRelayUnavailable, text)
+ case relayCodeBadRequest:
+ return fmt.Errorf("%w: %s", ErrRelayRequestInvalid, text)
+ default:
+ // A code from a newer replica. Carried out as-is rather than mapped
+ // onto the nearest known one, so a caller does not retry forever
+ // against a refusal that means something else entirely.
+ return fmt.Errorf("relay stream refused with unrecognised code %q: %s", code, text)
+ }
+}
+
+// maxRelayBudgetMillis is the largest budget a peer may declare, and exists so
+// the conversion below cannot overflow. A day is many orders of magnitude past
+// relayOpenTimeout, which is the only thing a budget is ever compared against,
+// so clamping to it changes no honest caller's behaviour.
+const maxRelayBudgetMillis = int64(24 * 60 * 60 * 1000)
+
+const (
+ // relayHeaderTimeout bounds how long a peer stream may go without naming
+ // the worker it is for. Without it, a dialler killed between OpenStream and
+ // its first write holds a relay goroutine and a stream slot until the whole
+ // peer link dies, which is minutes on the default keepalive.
+ relayHeaderTimeout = 15 * time.Second
+
+ // relayOpenTimeout is the CEILING on opening the worker-side stream. yamux
+ // blocks an Open once AcceptBacklog SYNs are in flight, waiting on synCh
+ // rather than failing (go-yamux/v5@v5.1.0/session.go:205-212); it honours
+ // the context, which is the only reason there is one here. Without the
+ // bound, a worker that has stopped accepting would turn a refusable
+ // condition into a parked peer, which is the one outcome this path exists
+ // to avoid.
+ //
+ // It is deliberately NOT configurable, and it is no longer the whole
+ // answer. The number that actually matters is how long the ORIGINAL client
+ // is willing to wait, which no deployment-wide constant can stand in for;
+ // the dialling replica now states it in the request frame and accept takes
+ // the SMALLER of the two. This remains the backstop for a caller that
+ // stated nothing, generous on purpose, because refusing healthy traffic
+ // costs more than waiting.
+ //
+ // The stated budget only ever SHORTENS the wait. A caller willing to wait
+ // an hour must not be able to park this replica's relay goroutine and a
+ // yamux stream slot for an hour on a worker that has stopped accepting.
+ relayOpenTimeout = 15 * time.Second
+)
+
+// Relay splices a stream a peer opened onto a worker tunnel this replica holds.
+//
+// It is the piece that makes more than one frontend replica work at all: a
+// worker holds ONE tunnel, it lands on ONE replica, and with N replicas behind
+// a load balancer roughly (N-1)/N of requests arrive somewhere else. Those
+// requests reach the worker through here.
+//
+// One hop, always. A stream naming a worker this replica does not hold is
+// refused, never resolved and relayed onward. A second hop would turn a stale
+// ownership row into a loop between two replicas, each certain the other holds
+// the worker, and the loop would carry the caller's request around it; the
+// dialling replica re-resolving the owner is both cheaper and terminating.
+type Relay struct {
+ tunnels *TunnelRegistry
+
+ // Timeouts are fields rather than constants read directly so a spec can
+ // exercise the deadline without waiting out a production value. They are
+ // not operator knobs and are not plumbed to configuration.
+ headerTimeout time.Duration
+ openTimeout time.Duration
+}
+
+// NewRelay returns the relay for the tunnels this replica holds. Its Stream
+// method is the SessionStore stream handler.
+func NewRelay(tunnels *TunnelRegistry) *Relay { return newRelay(tunnels, 0, 0) }
+
+func newRelay(tunnels *TunnelRegistry, headerTimeout, openTimeout time.Duration) *Relay {
+ return &Relay{
+ tunnels: tunnels,
+ headerTimeout: cmp.Or(headerTimeout, relayHeaderTimeout),
+ openTimeout: cmp.Or(openTimeout, relayOpenTimeout),
+ }
+}
+
+// Stream relays one peer stream. It owns closing that stream on every path.
+func (r *Relay) Stream(peerID string, stream net.Conn) {
+ // SessionStore runs this on a bare goroutine, so an unrecovered panic here
+ // ends the PROCESS, taking down every other replica's traffic through this
+ // one. It covers what runs on this goroutine: the frame read, the registry
+ // lookup and the open. It cannot cover a panic inside Splice's own copy
+ // goroutines, and it deliberately does not re-panic, because there is no
+ // recovery middleware above a goroutine the HTTP layer has already
+ // returned from.
+ defer func() {
+ if p := recover(); p != nil {
+ xlog.Error("Panic while relaying a peer stream", "peer", peerID, "panic", p)
+ _ = stream.Close()
+ }
+ }()
+
+ local, ok := r.accept(peerID, stream)
+ if !ok {
+ // accept has already answered and closed the stream.
+ return
+ }
+
+ // Splice owns closing both ends from here.
+ //
+ // The error is logged at DEBUG and nowhere else. Every relayed request that
+ // a client abandons mid-stream produces one, so a warning here would be one
+ // line per cancelled inference; and the failures that are not cancellations
+ // are already visible to the frontend, whose gRPC or HTTP client sees a
+ // response that ended without its trailers or its final chunk. What this
+ // line adds is the only view from the middle of the path: which node, on
+ // which peer link, and what yamux actually said.
+ if err := Splice(stream, local); err != nil {
+ xlog.Debug("relayed peer stream ended with an error", "peer", peerID, "error", err)
+ }
+}
+
+// accept reads which worker the stream is for and opens the worker-side stream.
+// The second result is false when the stream was refused, in which case the
+// refusal has been sent and the stream closed.
+func (r *Relay) accept(peerID string, stream net.Conn) (net.Conn, bool) {
+ if err := stream.SetReadDeadline(time.Now().Add(r.headerTimeout)); err != nil {
+ // Nothing is readable on a stream whose deadline cannot be set, so this
+ // is reported as infrastructure rather than pushed past.
+ r.refuse(peerID, stream, fmt.Errorf("%w: arming the request deadline: %v", ErrRelayUnavailable, err))
+ return nil, false
+ }
+
+ nodeID, budget, err := ReadRelayRequest(stream)
+ if err != nil {
+ // Includes the deadline above expiring. Both are "this stream never
+ // said which worker it wanted", which is the dialling replica's bug
+ // and not something a retry against this one resolves.
+ r.refuse(peerID, stream, fmt.Errorf("%w: %v", ErrRelayRequestInvalid, err))
+ return nil, false
+ }
+
+ // Cleared before the open rather than after the reply, and NOTHING arms
+ // another deadline on this stream afterwards. That is the intent rather
+ // than an omission: what follows is a relayed request whose length is the
+ // caller's business, and a header deadline left armed here would abort a
+ // long inference stream after any quiet moment in the middle of it. What
+ // still bounds the conversation is the peer link's own keepalive, which
+ // kills the session under it when the far side stops answering, and
+ // whatever deadline the original client is holding.
+ if err := stream.SetReadDeadline(time.Time{}); err != nil {
+ r.refuse(peerID, stream, fmt.Errorf("%w: clearing the request deadline: %v", ErrRelayUnavailable, err))
+ return nil, false
+ }
+
+ // Not the peer's deadline, because there is none to inherit: a yamux stream
+ // carries no context. This bounds only the open, so a request that gets
+ // past it is never cut short by it.
+ //
+ // The SMALLER of the ceiling and what the caller said it still has. Taking
+ // the caller's number when it is larger would let one patient client park a
+ // relay goroutine on a worker that has stopped accepting for as long as it
+ // liked; taking the ceiling when the caller's is smaller would keep waiting
+ // on behalf of a client that has already given up.
+ open := r.openTimeout
+ if budget > 0 && budget < open {
+ open = budget
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), open)
+ defer cancel()
+
+ local, err := r.tunnels.Open(ctx, nodeID)
+ if err != nil {
+ if errors.Is(err, ErrNotOwner) {
+ // Passed through as itself. The worker is very likely connected and
+ // healthy somewhere else, and this is the one answer that tells the
+ // caller to look for it there.
+ r.refuse(peerID, stream, err)
+ return nil, false
+ }
+ // Everything else is this replica failing, and it must NOT become
+ // ErrNotOwner. The tunnel is held right here, so sending the caller
+ // looking elsewhere would send it back to this same replica; and it
+ // must not become absence either, because the worker is attached and a
+ // scheduler told otherwise would reclaim what it is running.
+ r.refuse(peerID, stream, fmt.Errorf("%w: %v", ErrRelayUnavailable, err))
+ return nil, false
+ }
+
+ if err := WriteRelayAccepted(stream); err != nil {
+ // The peer never learns the stream was accepted, so it cannot be used.
+ // Closing the worker-side stream here is what stops one leaking per
+ // failed reply.
+ xlog.Debug("could not accept a peer stream for relaying", "peer", peerID, "node", nodeID, "error", err)
+ _ = local.Close()
+ _ = stream.Close()
+ return nil, false
+ }
+ return local, true
+}
+
+// refuse reports why a stream will not be relayed and then ENDS it.
+//
+// The close is the part that matters and it is not optional. A replica that
+// says why and leaves the stream open has parked the peer on a request that
+// will never be served, which reads as a slow replica rather than a refused
+// request, and no deadline on the far side can tell those apart. The reply is
+// what makes the refusal legible; the close is what makes it prompt.
+//
+// The reply is therefore best-effort and the close is not.
+func (r *Relay) refuse(peerID string, stream net.Conn, reason error) {
+ if err := WriteRelayRefusal(stream, reason); err != nil {
+ xlog.Debug("could not tell a peer why its stream was refused", "peer", peerID, "reason", reason, "error", err)
+ }
+ _ = stream.Close()
+}
diff --git a/core/services/cluster/relay_internal_test.go b/core/services/cluster/relay_internal_test.go
new file mode 100644
index 000000000000..2a5e631b0ccd
--- /dev/null
+++ b/core/services/cluster/relay_internal_test.go
@@ -0,0 +1,381 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// This spec is in-package because the header deadline it exercises is a
+// production constant measured in seconds, and a spec that waited it out would
+// be the slowest in the suite. The seam is unexported for the same reason the
+// worker tunnel's is: it is a test knob, not an operator knob.
+var _ = Describe("A peer stream that never says what it wants", func() {
+ It("is refused rather than left holding a relay goroutine", func() {
+ // Without a deadline on the opening frame, a peer that opens a stream
+ // and then goes quiet parks a goroutine and a stream slot until the
+ // whole session dies. A peer need not be malicious to do it: a dialler
+ // killed between OpenStream and its first write leaves exactly this.
+ relay := newRelay(NewTunnelRegistry(nil, "me"), 50*time.Millisecond, 0)
+ store := NewSessionStore(relay.Stream)
+ DeferCleanup(store.CloseAll)
+
+ a, b := net.Pipe()
+ accepted, err := yamux.Server(a, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ peer, err := yamux.Client(b, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = peer.Close()
+ _ = accepted.Close()
+ })
+ store.Accept("peer-1", accepted)
+
+ stream, err := peer.OpenStream(GinkgoT().Context())
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+
+ // Read with no deadline of our own: what is being asserted is that the
+ // RELAY answered, and a deadline here would be satisfied by a stream
+ // left parked just as well.
+ replies := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ replies <- ReadRelayReply(stream)
+ }()
+ var reply error
+ Eventually(replies, "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(ErrRelayRequestInvalid))
+
+ ends := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ _, err := stream.Read(make([]byte, 1))
+ ends <- err
+ }()
+ Eventually(ends, "10s").Should(Receive(HaveOccurred()))
+ })
+})
+
+// backloggedPair returns a peer/relay session pair whose SYN backlog is one
+// stream deep, so a single un-accepted open fills it and the next one parks.
+// yamux's default is 256 (mux.go, DefaultConfig), and filling that from a spec
+// would mean 256 real opens to prove one property.
+func backloggedPair(backlog int) (dialled, accepted *yamux.Session) {
+ GinkgoHelper()
+ cfg := yamux.DefaultConfig()
+ cfg.AcceptBacklog = backlog
+ a, b := net.Pipe()
+ var err error
+ accepted, err = yamux.Server(a, cfg, nil)
+ Expect(err).ToNot(HaveOccurred())
+ dialled, err = yamux.Client(b, cfg, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = dialled.Close()
+ _ = accepted.Close()
+ })
+ return dialled, accepted
+}
+
+// unwritableStream is a peer stream that delivers one relay request and then
+// fails every write. It stands in for a peer that vanished between opening the
+// stream and hearing the answer, which is the only way the acceptance reply
+// fails, and which no pair of live yamux sessions can be made to do on cue.
+type unwritableStream struct {
+ net.Conn
+ request []byte
+ read int
+ closed chan struct{}
+ closeOne sync.Once
+}
+
+func newUnwritableStream(nodeID string) *unwritableStream {
+ GinkgoHelper()
+ frame := &bytes.Buffer{}
+ Expect(WriteRelayRequest(frame, nodeID, 0)).To(Succeed())
+ return &unwritableStream{request: frame.Bytes(), closed: make(chan struct{})}
+}
+
+func (s *unwritableStream) Read(p []byte) (int, error) {
+ if s.read >= len(s.request) {
+ // Never EOF: an EOF here would end the relay for a reason other than
+ // the failed write, and the spec would pass without exercising it.
+ <-s.closed
+ return 0, io.EOF
+ }
+ n := copy(p, s.request[s.read:])
+ s.read += n
+ return n, nil
+}
+
+func (s *unwritableStream) Write([]byte) (int, error) { return 0, errors.New("peer went away") }
+
+func (s *unwritableStream) Close() error {
+ s.closeOne.Do(func() { close(s.closed) })
+ return nil
+}
+
+func (s *unwritableStream) SetReadDeadline(time.Time) error { return nil }
+
+var _ = Describe("The relay's own budgets", func() {
+ var (
+ reg *Registry
+ tun *TunnelRegistry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db := testutil.SetupTestDB()
+ Expect(Migrate(ctx, db)).To(Succeed())
+ reg = NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ tun = NewTunnelRegistry(reg, "me")
+ })
+
+ It("stops bounding the stream once the relay hands it over", func() {
+ // Both budgets are set to 50ms here and both are deliberately shorter
+ // than the window this spec then watches. A header deadline left armed
+ // past acceptance, or an open budget applied to the stream it produced,
+ // would abort a relayed inference after 50ms of quiet, which in
+ // production is the difference between a response that streams for an
+ // hour and one that dies mid-token.
+ relay := newRelay(tun, 50*time.Millisecond, 50*time.Millisecond)
+ store := NewSessionStore(relay.Stream)
+ DeferCleanup(store.CloseAll)
+ peer, accepted := backloggedPair(256)
+ store.Accept("peer-1", accepted)
+
+ worker, frontend := backloggedPair(256)
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ workerSide := make(chan net.Conn, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ return
+ }
+ workerSide <- stream
+ }()
+
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ Expect(WriteRelayRequest(stream, "w1", 0)).To(Succeed())
+
+ replies := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ replies <- ReadRelayReply(stream)
+ }()
+ Eventually(replies, "10s").Should(Receive(BeNil()))
+
+ var served net.Conn
+ Eventually(workerSide, "10s").Should(Receive(&served))
+
+ // One reader for both questions, so that watching for a teardown does
+ // not eat the bytes the second half of the spec is waiting for.
+ data := make(chan []byte, 4)
+ ended := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ buf := make([]byte, 64)
+ for {
+ n, err := served.Read(buf)
+ if n > 0 {
+ data <- append([]byte(nil), buf[:n]...)
+ }
+ if err != nil {
+ ended <- err
+ return
+ }
+ }
+ }()
+
+ // An assertion about an event that must NOT happen, which is the one
+ // kind a channel cannot replace: a torn-down splice ends the worker's
+ // side, and there is no event for "still alive". The window is ten
+ // times the budgets it is watching.
+ Consistently(ended, "500ms", "50ms").ShouldNot(Receive(),
+ "the relay tore the stream down on a budget that should have stopped applying at acceptance")
+
+ // And it is not merely un-torn-down: it still carries bytes, long after
+ // both budgets would have expired.
+ _, err = stream.Write([]byte("late"))
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(data, "10s").Should(Receive(Equal([]byte("late"))))
+ })
+
+ It("refuses rather than parking when the worker's tunnel will not take another stream", func() {
+ // The open budget exists because yamux BLOCKS an open once the accept
+ // backlog is full rather than failing it, so without a bound an
+ // overloaded worker turns a refusable condition into a parked peer.
+ relay := newRelay(tun, 0, 50*time.Millisecond)
+ store := NewSessionStore(relay.Stream)
+ DeferCleanup(store.CloseAll)
+ peer, accepted := backloggedPair(256)
+ store.Accept("peer-1", accepted)
+
+ _, frontend := backloggedPair(1)
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ // One un-accepted open fills the one-deep backlog; the relay's own open
+ // is the one that has to wait.
+ filler, err := frontend.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = filler.Close() })
+
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ Expect(WriteRelayRequest(stream, "w1", 0)).To(Succeed())
+
+ replies := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ replies <- ReadRelayReply(stream)
+ }()
+ var reply error
+ Eventually(replies, "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(ErrRelayUnavailable))
+ // The tunnel IS held here, so this must not read as a routing fact.
+ Expect(reply).ToNot(MatchError(ErrNotOwner))
+
+ ends := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ _, err := stream.Read(make([]byte, 1))
+ ends <- err
+ }()
+ Eventually(ends, "10s").Should(Receive(HaveOccurred()))
+ })
+
+ It("bounds its open by the caller's stated budget when that is the shorter", func() {
+ // The ceiling here is 10s and the caller says it has 50ms. Without the
+ // stated budget this replica would hold a relay goroutine and a yamux
+ // stream slot for the full ceiling on behalf of a client that gave up
+ // almost immediately.
+ relay := newRelay(tun, 0, 10*time.Second)
+ store := NewSessionStore(relay.Stream)
+ DeferCleanup(store.CloseAll)
+ peer, accepted := backloggedPair(256)
+ store.Accept("peer-1", accepted)
+
+ _, frontend := backloggedPair(1)
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ // One un-accepted open fills the one-deep backlog, so the relay's own
+ // open is the one that has to wait out a budget.
+ filler, err := frontend.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = filler.Close() })
+
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ Expect(WriteRelayRequest(stream, "w1", 50*time.Millisecond)).To(Succeed())
+
+ replies := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ replies <- ReadRelayReply(stream)
+ }()
+ var reply error
+ // Two seconds is twenty times the stated budget and a fifth of the
+ // ceiling, so only a relay that honoured the budget answers inside it.
+ Eventually(replies, "2s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(ErrRelayUnavailable))
+ // The tunnel IS held here. A budget running out must not turn into a
+ // routing fact, and it must never become absence.
+ Expect(reply).ToNot(MatchError(ErrNotOwner))
+ Expect(reply).ToNot(MatchError(ErrNoConnection))
+ })
+
+ It("does not let a stated budget stretch its own ceiling", func() {
+ // A patient client must not be able to park this replica. The ceiling
+ // is 50ms and the caller says it will wait ten seconds; the refusal
+ // still has to arrive on the ceiling.
+ relay := newRelay(tun, 0, 50*time.Millisecond)
+ store := NewSessionStore(relay.Stream)
+ DeferCleanup(store.CloseAll)
+ peer, accepted := backloggedPair(256)
+ store.Accept("peer-1", accepted)
+
+ _, frontend := backloggedPair(1)
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ filler, err := frontend.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = filler.Close() })
+
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ Expect(WriteRelayRequest(stream, "w1", 10*time.Second)).To(Succeed())
+
+ replies := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ replies <- ReadRelayReply(stream)
+ }()
+ var reply error
+ Eventually(replies, "2s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(ErrRelayUnavailable))
+ })
+
+ It("closes the worker's stream when it cannot tell the peer the stream was accepted", func() {
+ // The reply is the last thing that can fail after a worker stream has
+ // been opened. A relay that gave up without closing it would leak one
+ // stream on the worker per failed acceptance, and the worker cannot
+ // tell those from live ones.
+ relay := newRelay(tun, 0, 0)
+ worker, frontend := backloggedPair(256)
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ workerSide := make(chan net.Conn, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ return
+ }
+ workerSide <- stream
+ }()
+
+ peerStream := newUnwritableStream("w1")
+ done := make(chan struct{})
+ go func() {
+ defer GinkgoRecover()
+ defer close(done)
+ relay.Stream("peer-1", peerStream)
+ }()
+ Eventually(done, "10s").Should(BeClosed())
+
+ var served net.Conn
+ Eventually(workerSide, "10s").Should(Receive(&served))
+ ended := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ _, err := served.Read(make([]byte, 1))
+ ended <- err
+ }()
+ Eventually(ended, "10s").Should(Receive(HaveOccurred()),
+ "the worker-side stream outlived the relay that opened it")
+ })
+})
diff --git a/core/services/cluster/relay_test.go b/core/services/cluster/relay_test.go
new file mode 100644
index 000000000000..04fa46f20285
--- /dev/null
+++ b/core/services/cluster/relay_test.go
@@ -0,0 +1,326 @@
+// SPDX-License-Identifier: MIT
+
+package cluster_test
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+// blockingRead runs one Read on its own goroutine with NO deadline set.
+//
+// The absence of the deadline is the point. A refusal and a stream left parked
+// are indistinguishable to an assertion that waits for a deadline to expire:
+// both produce an error at the same moment. Reading with no deadline at all
+// means the channel only ever receives because the far side ANSWERED or ENDED
+// the stream, so Eventually(...).Should(Receive()) is an assertion about the
+// relay rather than about the clock.
+func blockingRead(conn net.Conn) chan error {
+ done := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ _, err := conn.Read(make([]byte, 1))
+ done <- err
+ }()
+ return done
+}
+
+// relayReply reads the relay's answer, on its own goroutine and with no
+// deadline, for the reason blockingRead gives.
+func relayReply(conn net.Conn) chan error {
+ done := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ done <- cluster.ReadRelayReply(conn)
+ }()
+ return done
+}
+
+// readInto reads exactly len(buf) bytes on its own goroutine, with no deadline.
+func readInto(conn net.Conn, buf []byte) chan error {
+ done := make(chan error, 1)
+ go func() {
+ defer GinkgoRecover()
+ _, err := io.ReadFull(conn, buf)
+ done <- err
+ }()
+ return done
+}
+
+// acceptOne hands back the next stream accepted on a session.
+func acceptOne(sess *yamux.Session) chan net.Conn {
+ accepted := make(chan net.Conn, 1)
+ go func() {
+ defer GinkgoRecover()
+ stream, err := sess.AcceptStream()
+ if err != nil {
+ return
+ }
+ accepted <- stream
+ }()
+ return accepted
+}
+
+var _ = Describe("The inter-replica relay", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ tun *cluster.TunnelRegistry
+ ctx context.Context
+
+ // peer is the dialling replica's half of the peer link, the side a
+ // relayed request arrives from.
+ peer *yamux.Session
+ )
+
+ // openRelayStream opens a peer stream and names the node it is for.
+ openRelayStream := func(nodeID string) net.Conn {
+ GinkgoHelper()
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ Expect(cluster.WriteRelayRequest(stream, nodeID, 0)).To(Succeed())
+ return stream
+ }
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ tun = cluster.NewTunnelRegistry(reg, "me")
+
+ store := cluster.NewSessionStore(cluster.NewRelay(tun).Stream)
+ DeferCleanup(store.CloseAll)
+ var accepted *yamux.Session
+ peer, accepted = yamuxPair()
+ store.Accept("peer-1", accepted)
+ })
+
+ It("splices a peer's stream onto a worker tunnel it holds, in both directions", func() {
+ // This is the whole point of the relay: with one tunnel per worker
+ // landing on ONE replica, every other replica reaches that worker only
+ // by relaying through this path, so with N replicas it carries roughly
+ // (N-1)/N of production traffic.
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ echoOnce(worker)
+
+ stream := openRelayStream("w1")
+ Eventually(relayReply(stream), "10s").Should(Receive(BeNil()))
+
+ _, err = stream.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ echoed := make([]byte, 4)
+ Eventually(readInto(stream, echoed), "10s").Should(Receive(BeNil()))
+ Expect(string(echoed)).To(Equal("ping"))
+ })
+
+ It("does not forward the frame it consumed, so the worker sees only the tunnelled protocol", func() {
+ // The relay request names the node for THIS hop and stops here. The
+ // worker's own request frame is written by the dialling replica and
+ // crosses untouched, so a relay that forwarded its own header would
+ // make every relayed stream unparseable at the worker while every
+ // locally-held one worked.
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ accepted := acceptOne(worker)
+
+ stream := openRelayStream("w1")
+ Eventually(relayReply(stream), "10s").Should(Receive(BeNil()))
+ _, err = stream.Write([]byte("first"))
+ Expect(err).ToNot(HaveOccurred())
+
+ var workerSide net.Conn
+ Eventually(accepted, "10s").Should(Receive(&workerSide))
+ first := make([]byte, 5)
+ Eventually(readInto(workerSide, first), "10s").Should(Receive(BeNil()))
+ Expect(string(first)).To(Equal("first"))
+ })
+
+ It("tears down the worker's side when the peer's side closes", func() {
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ accepted := acceptOne(worker)
+
+ stream := openRelayStream("w1")
+ Eventually(relayReply(stream), "10s").Should(Receive(BeNil()))
+ var workerSide net.Conn
+ Eventually(accepted, "10s").Should(Receive(&workerSide))
+
+ Expect(stream.Close()).To(Succeed())
+ // A relay that copies but does not tear down leaves a backend
+ // connection per abandoned request, and a worker runs out of them.
+ Eventually(blockingRead(workerSide), "10s").Should(Receive(HaveOccurred()))
+ })
+
+ It("tears down the peer's side when the worker's side closes", func() {
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ accepted := acceptOne(worker)
+
+ stream := openRelayStream("w1")
+ Eventually(relayReply(stream), "10s").Should(Receive(BeNil()))
+ var workerSide net.Conn
+ Eventually(accepted, "10s").Should(Receive(&workerSide))
+
+ Expect(workerSide.Close()).To(Succeed())
+ Eventually(blockingRead(stream), "10s").Should(Receive(MatchError(io.EOF)))
+ })
+
+ It("refuses a node it does not hold with the routing fact, and ENDS the stream", func() {
+ stream := openRelayStream("not-here")
+
+ var reply error
+ Eventually(relayReply(stream), "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(cluster.ErrNotOwner))
+ // Four conditions this phase forbids collapsing. ErrNotOwner says
+ // "ask the owner"; absence says "this worker is gone" and a scheduler
+ // acts on that; unreachability says "retry".
+ Expect(reply).ToNot(MatchError(cluster.ErrNoConnection))
+ Expect(reply).ToNot(MatchError(cluster.ErrPeerUnreachable))
+ Expect(reply).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ Expect(reply).ToNot(MatchError(cluster.ErrRelayUnavailable))
+
+ // Answering is not enough. A relay that says why and leaves the stream
+ // open has parked the peer on a request that will never be served,
+ // which reads as a slow replica rather than a refused request.
+ Eventually(blockingRead(stream), "10s").Should(Receive(MatchError(io.EOF)))
+ })
+
+ It("refuses rather than chasing a node another live replica owns", func() {
+ // A relay that resolved the owner and relayed onward would make a
+ // stale row into a loop between two replicas, each certain the other
+ // holds the worker. One hop, always: the dialling replica re-resolves.
+ Expect(reg.Register(ctx, "other", "10.0.0.2:8080", "v1")).To(Succeed())
+ _, err := reg.Claim(ctx, "w1", "other")
+ Expect(err).ToNot(HaveOccurred())
+
+ stream := openRelayStream("w1")
+ var reply error
+ Eventually(relayReply(stream), "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(cluster.ErrNotOwner))
+ Eventually(blockingRead(stream), "10s").Should(Receive(MatchError(io.EOF)))
+ })
+
+ It("reports a tunnel that will not carry a stream as infrastructure, never as not-owner", func() {
+ // The tunnel IS held here; its session died. Answering ErrNotOwner
+ // would send the dialling replica looking elsewhere for a worker that
+ // is attached right here, and it would find this replica again.
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(worker.Close()).To(Succeed())
+ Eventually(frontend.IsClosed, "10s").Should(BeTrue())
+
+ stream := openRelayStream("w1")
+ var reply error
+ Eventually(relayReply(stream), "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(cluster.ErrRelayUnavailable))
+ Expect(reply).ToNot(MatchError(cluster.ErrNotOwner))
+ Expect(reply).ToNot(MatchError(cluster.ErrNoConnection))
+ Expect(reply).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ Eventually(blockingRead(stream), "10s").Should(Receive(MatchError(io.EOF)))
+ })
+
+ It("refuses a malformed opening frame as the caller's bug", func() {
+ stream, err := peer.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+ // A well-formed frame carrying no node id. A relay that read this as a
+ // node named "" would go looking for it and answer ErrNotOwner, which
+ // tells the caller to retry elsewhere for a request no replica can
+ // ever serve.
+ _, err = stream.Write([]byte{0x00, 0x00})
+ Expect(err).ToNot(HaveOccurred())
+
+ var reply error
+ Eventually(relayReply(stream), "10s").Should(Receive(&reply))
+ Expect(reply).To(MatchError(cluster.ErrRelayRequestInvalid))
+ Expect(reply).ToNot(MatchError(cluster.ErrNotOwner))
+ Expect(reply).ToNot(MatchError(cluster.ErrRelayUnavailable))
+ Eventually(blockingRead(stream), "10s").Should(Receive(MatchError(io.EOF)))
+ })
+})
+
+var _ = Describe("The relay wire framing", func() {
+ // The relay hop and the worker tunnel hop travel back to back on one
+ // stream, and a dialler reads a reply from each in order. Giving them
+ // disjoint vocabularies means a reader applied to the wrong hop fails
+ // loudly rather than returning a plausible sentinel for the other hop,
+ // which would report a worker's refusal as the owning replica's and send a
+ // retry to the wrong place.
+ It("does not read a relay reply as a worker tunnel reply", func() {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteRelayAccepted(frame)).To(Succeed())
+ Expect(cluster.ReadStreamReply(frame)).To(HaveOccurred())
+ })
+
+ It("does not read a worker tunnel reply as a relay reply", func() {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteStreamAccepted(frame)).To(Succeed())
+ Expect(cluster.ReadRelayReply(frame)).To(HaveOccurred())
+ })
+
+ It("round-trips a node id", func() {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteRelayRequest(frame, "node-7", 0)).To(Succeed())
+ nodeID, _, err := cluster.ReadRelayRequest(frame)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(nodeID).To(Equal("node-7"))
+ })
+
+ It("refuses to write an empty node id, rather than spending a round trip on it", func() {
+ Expect(cluster.WriteRelayRequest(&bytes.Buffer{}, "", 0)).To(HaveOccurred())
+ })
+
+ // Acceptance is not the whole surface. A refusal read by the wrong hop's
+ // reader must not come back as one of that hop's own sentinels: "the
+ // owning replica does not hold this worker" arriving as "the worker does
+ // not serve that tag" would send a retry to the wrong end of the path, and
+ // it would look like a perfectly ordinary answer on the way.
+ DescribeTable("does not read a relay refusal as one of the worker tunnel's",
+ func(reason error) {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteRelayRefusal(frame, reason)).To(Succeed())
+ err := cluster.ReadStreamReply(frame)
+ Expect(err).To(HaveOccurred())
+ Expect(err).ToNot(MatchError(cluster.ErrStreamTagUnknown))
+ Expect(err).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(err).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ },
+ Entry("not the owner", cluster.ErrNotOwner),
+ Entry("the tunnel will not carry a stream", cluster.ErrRelayUnavailable),
+ Entry("a malformed relay request", cluster.ErrRelayRequestInvalid),
+ )
+
+ DescribeTable("does not read a worker tunnel refusal as one of the relay's",
+ func(reason error) {
+ frame := &bytes.Buffer{}
+ Expect(cluster.WriteStreamRefusal(frame, reason)).To(Succeed())
+ err := cluster.ReadRelayReply(frame)
+ Expect(err).To(HaveOccurred())
+ Expect(err).ToNot(MatchError(cluster.ErrNotOwner))
+ Expect(err).ToNot(MatchError(cluster.ErrRelayUnavailable))
+ Expect(err).ToNot(MatchError(cluster.ErrRelayRequestInvalid))
+ },
+ Entry("an unknown stream tag", cluster.ErrStreamTagUnknown),
+ Entry("a local service that will not answer", cluster.ErrStreamTargetUnavailable),
+ Entry("a malformed stream request", cluster.ErrStreamRequestInvalid),
+ )
+})
diff --git a/core/services/cluster/sessions.go b/core/services/cluster/sessions.go
new file mode 100644
index 000000000000..aa52fa8d4842
--- /dev/null
+++ b/core/services/cluster/sessions.go
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "net"
+ "sync"
+
+ "github.com/libp2p/go-yamux/v5"
+ "github.com/mudler/xlog"
+)
+
+// SessionStore holds the peer links this replica has ACCEPTED, which is the
+// mirror image of PeerPool: the pool owns the sessions this replica dialled,
+// this owns the ones its peers dialled into it.
+//
+// Something has to own an accepted session. The HTTP handler cannot: it returns
+// as soon as the upgrade is done, and the hijacked connection outlives it. And
+// something has to accept the streams that arrive on it, because yamux only
+// acknowledges a stream once the far side accepts it, so a session nobody
+// accepts on does not fail a peer's Open, it hangs it.
+type SessionStore struct {
+ // onStream handles one accepted stream and owns closing it. A nil handler
+ // closes the stream immediately, which is what a replica with no relay
+ // installed should do: refuse promptly rather than leave a peer parked.
+ //
+ // In distributed mode this is Relay.Stream, which splices the stream onto
+ // a worker tunnel this replica holds. Nil is reached only from specs, and
+ // from a caller that wants a store with no relay.
+ onStream func(peerID string, stream net.Conn)
+
+ mu sync.Mutex
+ sessions map[string]*yamux.Session
+ closed bool
+}
+
+// NewSessionStore returns a store whose accepted streams are handled by
+// onStream. Pass nil to refuse every stream, closing it at once.
+func NewSessionStore(onStream func(peerID string, stream net.Conn)) *SessionStore {
+ return &SessionStore{onStream: onStream, sessions: map[string]*yamux.Session{}}
+}
+
+// Accept takes ownership of a session a peer dialled in. It is the callback
+// shape RegisterClusterRoutes wants, and it returns promptly: the serving loop
+// runs on its own goroutine, because the handler's return is what completes the
+// hijack.
+func (s *SessionStore) Accept(peerID string, sess *yamux.Session) {
+ if sess == nil {
+ return
+ }
+
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ // Shutdown raced the dial. Leaving the session open would keep the peer
+ // believing it has a live link into a process that is going away.
+ _ = sess.Close()
+ return
+ }
+ previous := s.sessions[peerID]
+ s.sessions[peerID] = sess
+ s.mu.Unlock()
+
+ // A peer that dials again has lost its previous link, whether or not this
+ // side has noticed. Keeping both would leave a session nothing can ever be
+ // routed to, since the map holds one per peer.
+ if previous != nil {
+ xlog.Debug("cluster peer re-dialled, dropping its previous link", "peer", peerID)
+ _ = previous.Close()
+ }
+
+ go s.serve(peerID, sess)
+}
+
+// Get returns the session this replica accepted from peerID. The second result
+// is false when no link from that peer is held, which a caller must not read as
+// the peer being absent: it may be about to dial, or dialling this replica may
+// simply not be its job.
+//
+// It has NO production caller, and that is stated rather than left to be
+// discovered: nothing in the frontend routes by looking up an inbound link,
+// because the relay is driven by the streams a peer opens on the session, not
+// by this side going to find one. What Get exists for is the specs, which have
+// no other way to observe which session this store holds, and holding exactly
+// one session per peer is the property Accept's eviction is about. Deleting it
+// would delete that observation with it. Anything tempted to route on it should
+// read the paragraph above first: a missing entry is not an absent peer.
+func (s *SessionStore) Get(peerID string) (*yamux.Session, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ sess, ok := s.sessions[peerID]
+ return sess, ok
+}
+
+// serve accepts streams until the session dies, then forgets it.
+func (s *SessionStore) serve(peerID string, sess *yamux.Session) {
+ defer func() {
+ s.forget(peerID, sess)
+ _ = sess.Close()
+ }()
+
+ for {
+ stream, err := sess.AcceptStream()
+ if err != nil {
+ // A peer link ending is ordinary: a rolling update closes every
+ // session it holds. The error is the session's, not one stream's,
+ // so there is nothing to recover to.
+ xlog.Debug("cluster peer link ended", "peer", peerID, "error", err)
+ return
+ }
+ if s.onStream == nil {
+ // No relay installed. Closing is deliberate and is not the same as
+ // ignoring: a stream nobody answers parks the peer's request until
+ // its own deadline, and reports nothing about why.
+ xlog.Debug("cluster peer stream refused: no relay installed", "peer", peerID)
+ _ = stream.Close()
+ continue
+ }
+ // One goroutine per stream: the handler relays a whole request, and
+ // serving them from the accept loop would let one request stall every
+ // other stream on the link.
+ go s.onStream(peerID, stream)
+ }
+}
+
+// forget drops the entry only if it still names this session. A peer that
+// re-dialled has already replaced it, and deleting blindly would evict the live
+// link when the old one finally noticed it was dead.
+func (s *SessionStore) forget(peerID string, sess *yamux.Session) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.sessions[peerID] == sess {
+ delete(s.sessions, peerID)
+ }
+}
+
+// CloseAll drops every held session. An Accept after it closes the session
+// rather than storing it, so a dial racing shutdown cannot leak a link.
+func (s *SessionStore) CloseAll() {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ held := s.sessions
+ s.sessions = map[string]*yamux.Session{}
+ s.mu.Unlock()
+
+ for _, sess := range held {
+ _ = sess.Close()
+ }
+}
diff --git a/core/services/cluster/sessions_test.go b/core/services/cluster/sessions_test.go
new file mode 100644
index 000000000000..29ca9b537d42
--- /dev/null
+++ b/core/services/cluster/sessions_test.go
@@ -0,0 +1,134 @@
+package cluster_test
+
+import (
+ "io"
+ "net"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// yamuxPair returns a client and a server session over an in-memory pipe. It
+// stands in for a dialled peer link: everything the store does with a session
+// is transport-agnostic, and the WebSocket half is covered where it is used.
+func yamuxPair() (client *yamux.Session, server *yamux.Session) {
+ GinkgoHelper()
+ a, b := net.Pipe()
+ var err error
+ server, err = yamux.Server(a, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ client, err = yamux.Client(b, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = client.Close()
+ _ = server.Close()
+ })
+ return client, server
+}
+
+// refusalDeadline bounds how long a refused stream may take to end. A refusal
+// is one frame from a peer that already decided, so anything near this is the
+// hang it exists to detect.
+const refusalDeadline = 2 * time.Second
+
+var _ = Describe("Accepted peer sessions", func() {
+ It("accepts and refuses a stream rather than leaving the peer parked", func() {
+ // yamux only acknowledges a stream once the far side accepts it, so a
+ // store that held the session without accepting would not fail a peer's
+ // Open, it would hang it, and every relayed request behind it.
+ store := cluster.NewSessionStore(nil)
+ DeferCleanup(store.CloseAll)
+ client, server := yamuxPair()
+ store.Accept("peer-1", server)
+
+ stream, err := client.OpenStream(GinkgoT().Context())
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+
+ // The deadline is short and is NOT the thing being asserted: yamux
+ // reports a deadline as ErrTimeout, and requiring an ending instead
+ // (EOF from the peer's Close, or a reset) is what separates "refused"
+ // from "parked". An earlier version asserted only that some error
+ // arrived, which a parked stream satisfies just as well.
+ Expect(stream.SetReadDeadline(time.Now().Add(refusalDeadline))).To(Succeed())
+ _, err = stream.Read(make([]byte, 1))
+ Expect(err).To(SatisfyAny(MatchError(io.EOF), MatchError(yamux.ErrStreamReset)),
+ "a refused stream must END within %s; %v means the peer accepted it and then left it parked", refusalDeadline, err)
+ })
+
+ It("hands a stream to the relay when one is installed", func() {
+ streams := make(chan net.Conn, 1)
+ store := cluster.NewSessionStore(func(_ string, stream net.Conn) { streams <- stream })
+ DeferCleanup(store.CloseAll)
+ client, server := yamuxPair()
+ store.Accept("peer-1", server)
+
+ stream, err := client.OpenStream(GinkgoT().Context())
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+
+ var relayed net.Conn
+ Eventually(streams, "10s").Should(Receive(&relayed))
+ go func() {
+ defer GinkgoRecover()
+ _, _ = stream.Write([]byte("hello"))
+ }()
+ buf := make([]byte, 5)
+ Expect(relayed.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+ _, err = relayed.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("hello"))
+ })
+
+ It("replaces a peer's link when it dials again, and closes the one it lost", func() {
+ // A peer only re-dials because its previous link is gone from where it
+ // stands. Keeping both would leave a session nothing can be routed to,
+ // since the store holds one per peer.
+ store := cluster.NewSessionStore(nil)
+ DeferCleanup(store.CloseAll)
+ _, first := yamuxPair()
+ _, second := yamuxPair()
+
+ store.Accept("peer-1", first)
+ store.Accept("peer-1", second)
+
+ held, ok := store.Get("peer-1")
+ Expect(ok).To(BeTrue())
+ Expect(held).To(BeIdenticalTo(second))
+ Eventually(first.IsClosed, "10s").Should(BeTrue())
+ Expect(second.IsClosed()).To(BeFalse(), "the link the peer is actually using was dropped")
+ })
+
+ It("forgets a session that ended, without evicting the one that replaced it", func() {
+ store := cluster.NewSessionStore(nil)
+ DeferCleanup(store.CloseAll)
+ client, server := yamuxPair()
+ store.Accept("peer-1", server)
+
+ Expect(client.Close()).To(Succeed())
+ Eventually(func() bool {
+ _, ok := store.Get("peer-1")
+ return ok
+ }, "10s").Should(BeFalse())
+ })
+
+ It("closes every held link on shutdown, and refuses to store one afterwards", func() {
+ store := cluster.NewSessionStore(nil)
+ _, server := yamuxPair()
+ store.Accept("peer-1", server)
+
+ store.CloseAll()
+ Eventually(server.IsClosed, "10s").Should(BeTrue())
+
+ _, late := yamuxPair()
+ store.Accept("peer-late", late)
+ _, ok := store.Get("peer-late")
+ Expect(ok).To(BeFalse())
+ Eventually(late.IsClosed, "10s").Should(BeTrue(),
+ "a dial racing shutdown must not be left believing it holds a live link")
+ })
+})
diff --git a/core/services/cluster/splice.go b/core/services/cluster/splice.go
new file mode 100644
index 000000000000..32fe4358f3d7
--- /dev/null
+++ b/core/services/cluster/splice.go
@@ -0,0 +1,223 @@
+package cluster
+
+import (
+ "errors"
+ "io"
+ "net"
+
+ "github.com/libp2p/go-yamux/v5"
+)
+
+// Splice joins two streams and copies bytes between them in both directions
+// until one direction finishes, then closes both so the other unblocks and
+// returns. It is the primitive under the inter-replica relay and the worker
+// tunnel, so it carries gRPC: both directions can be live at once and either
+// peer may speak first, which is why the copies run concurrently. A sequential
+// io.Copy then io.Copy would deadlock waiting for a request on a stream whose
+// far side is waiting for a response.
+//
+// EOF in one direction therefore truncates whatever is still in flight in the
+// other. That is right for gRPC, HTTP/2 and yamux, which end a stream in both
+// directions at once, but a future caller relaying raw TCP with a half-close
+// would lose the response body still arriving after the request's CloseWrite.
+//
+// The error reported is the one from the direction that finished first, with
+// the endings that mean "someone closed" mapped to nil. The other direction's
+// error is dropped; most of the time it is an echo of the Close below, but it
+// can also be a genuine failure that lost the race, so a Splice error means
+// "one direction failed", never "only this failed".
+func Splice(a, b io.ReadWriteCloser) error {
+ errs := make(chan error, 2)
+ go func() { errs <- copyStream(b, a) }()
+ go func() { errs <- copyStream(a, b) }()
+
+ first := <-errs
+
+ // Closing both ends is what releases the other direction, whether it is
+ // parked in Read or halfway through a Write nobody is draining. Each end
+ // is closed exactly once, here and nowhere else, which keeps the error
+ // below meaningful: a second Close of a yamux stream that was reset
+ // returns the error that killed it, and Splice would have no way to tell
+ // that from a fresh failure.
+ closeErrA := a.Close()
+ closeErrB := b.Close()
+
+ // Wait for the second direction so no copy is still touching either stream
+ // once Splice has returned. This is load-bearing: it assumes Close unblocks
+ // a copy parked in Read or Write, and a stream where that is false hangs
+ // here rather than leaking a goroutine. The two stream types this is built
+ // for satisfy it: net.Conn does, and so does go-yamux/v5, whose Close sets
+ // readErr and calls notifyWaiting to wake a parked Read while a parked
+ // Write returns ErrStreamClosed. Nothing outside this package's own specs
+ // calls Splice yet, so a phase 2 caller relaying over anything else has to
+ // check this property rather than assume it.
+ <-errs
+
+ if first != nil {
+ return first
+ }
+ // A close that fails on a stream that was otherwise healthy is worth
+ // reporting; a close of an already-dead stream is not.
+ if err := normalizeStreamErr(closeErrA); err != nil {
+ return err
+ }
+ return normalizeStreamErr(closeErrB)
+}
+
+// copyStream moves one direction and reports only genuine transport failures.
+func copyStream(dst io.Writer, src io.Reader) error {
+ _, err := io.Copy(dst, src)
+ return normalizeStreamErr(err)
+}
+
+// normalizeStreamErr drops the endings that mean the conversation is over
+// rather than broken: net.ErrClosed is what a socket reports once it or its
+// peer has been closed, and io.ErrClosedPipe is the same condition on an
+// in-memory pipe.
+//
+// io.EOF is deliberately absent. A clean read-side EOF never gets this far,
+// because io.Copy consumes it and reports nil, and neither *yamux.Stream nor
+// *net.TCPConn takes a WriteTo/ReadFrom path that would hand one back. So a
+// bare io.EOF arriving here came from a failing Write or Close, where it means
+// the peer is gone, and yamux produces exactly that when a Write races its
+// session's shutdown (see muxVerdict).
+//
+// A socket-level abort (ECONNRESET, EPIPE) is deliberately absent too, which
+// makes the same underlying event, a peer aborting mid-stream, reach the caller
+// as nil over a raw socket where it would be an error. That asymmetry is
+// narrower than it was, since a yamux abort is now reported (see muxVerdict),
+// and what remains of it is that a raw socket cannot say who aborted.
+//
+// The mux verdict is consulted first, and that ordering is load-bearing: a
+// dying yamux session usually hands every live stream its own cause wrapped up
+// (go-yamux/v5@v5.1.0 session.go, Session.close), and that cause is routinely a
+// closed-socket error, so consulting the generic endings first would report a
+// peer that vanished mid-request as a clean completion.
+func normalizeStreamErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ if recognised, report := muxVerdict(err); recognised {
+ if report {
+ return err
+ }
+ return nil
+ }
+ if errors.Is(err, net.ErrClosed) ||
+ errors.Is(err, io.ErrClosedPipe) {
+ return nil
+ }
+ return err
+}
+
+// normalGoAwayCode is yamux's "no error" go-away code, read off a sentinel
+// declared with it because the constant itself is unexported.
+var normalGoAwayCode = yamux.ErrRemoteGoAway.ErrorCode
+
+// muxVerdict classifies a yamux ending. recognised says the error came from the
+// multiplexer at all; report says the ending was INFLICTED on this stream
+// rather than asked for by this side.
+//
+// It is ONE function, and that is the point rather than a matter of taste. The
+// policy below turns on a single bit, Remote, and an earlier shape read that
+// bit in two predicates with a report-by-default fallthrough behind them.
+// Reverting either read left the whole suite green, because the error reached
+// the same answer down the other path: the classifier could not be
+// mutation-tested in pieces, which in code whose correctness argument IS its
+// mutation evidence is worse than the duplication it bought. Here each type is
+// decided once, so falsifying either read reddens a spec.
+//
+// The distinction it draws is what keeps Splice quiet about the teardown it
+// provokes itself while still reporting a request that died: a keepalive
+// timeout, a broken connection, a peer that reset the stream or a peer that
+// went away under a relayed request has to reach the caller, or a failed
+// inference looks like a finished one.
+//
+// TWO OF THESE ARE THE POLICY PHASE 1 LEFT OPEN, and this is where they are
+// settled, by the relay in core/services/cluster/relay.go, which is Splice's
+// first production caller. Both used to be reported as normal termination.
+// Neither can be settled by a caller reading Splice's result, because a result
+// mapped to nil carries nothing left to reclassify, so the decision has to live
+// at the classifier; the two callers there are the relay and the worker tunnel,
+// and both are splicing an in-flight request, so both want the same answer.
+//
+// 1. A peer-initiated stream reset, *StreamError{Remote: true}. yamux builds
+// it in processFlags when an RST frame arrives on the stream
+// (stream.go:432-449); a reset this side asked for carries Remote: false
+// instead (stream.go:283-291), and Splice never resets anything anyway, its
+// own Close sending a FIN (stream.go:303-331, 365-368). So Remote: true is
+// unambiguously "the far side aborted this stream", which for a relayed
+// request means the response was truncated. REPORTED. The caller decides
+// how loud that is: a client cancelling produces one per cancellation, so
+// the relay logs it at debug rather than treating it as a fault.
+//
+// 2. A graceful go-away from the peer, ErrRemoteGoAway. handleGoAway returns
+// it for code goAwayNormal (session.go:829-833), recv closes the session
+// with it, and close hands it UNWRAPPED to every live stream, because it
+// already is a *GoAwayError and so escapes the ErrStreamReset wrapping
+// (session.go:328-337, stream.go:371-387). Graceful describes the SESSION,
+// not the requests on it: every one of those streams was mid-request.
+// REPORTED, for the same reason as above.
+//
+// The locally-initiated forms of both stay silent, and keying on Remote is what
+// separates them: ErrSessionShutdown is a *GoAwayError with Remote: false
+// (const.go:96) and is exactly what this process closing its own session
+// produces (session.go:284).
+//
+// The rule is "a remote reset is reported" and not "every remote reset is
+// reported". yamux only builds a *StreamError when the RST rides a
+// typeWindowUpdate frame (stream.go:436); an RST on any other frame type
+// yields the BARE ErrStreamReset sentinel, which is claimed below as this
+// side's own teardown and silenced. Every reset go-yamux itself sends uses
+// typeWindowUpdate, so the gap is unreachable between two LocalAI processes and
+// only a foreign multiplexer implementation could reach it.
+//
+// What this does NOT do is make the far side see a failure. Splice ends both
+// streams with Close, which is a FIN, and a reset after that is a no-op because
+// Close has already moved the stream to streamFinished (stream.go:266-272,
+// 303-331, 336-361). Propagating a truncation as an RST would mean reshaping
+// Splice's teardown, and it buys little: the protocols relayed here are gRPC
+// and HTTP, both of which detect a body that ended without its trailers or its
+// final chunk. Reporting is what the caller needs and this is where it comes
+// from.
+func muxVerdict(err error) (recognised, report bool) {
+ // A go-away ends the whole session. Only a normal-code go-away this side
+ // sent is a normal ending.
+ var goAway *yamux.GoAwayError
+ if errors.As(err, &goAway) {
+ return true, goAway.Remote || goAway.ErrorCode != normalGoAwayCode
+ }
+ // A stream error is scoped to one stream. Only a reset this side asked for
+ // is a normal ending.
+ var streamErr *yamux.StreamError
+ if errors.As(err, &streamErr) {
+ return true, streamErr.Remote
+ }
+ // Sentinels by identity, never errors.Is, and before the wrapped check
+ // below: these are the endings Splice provokes itself. Closing a stream
+ // whose session has already shut down normally returns ErrSessionShutdown
+ // from the FIN write, which the go-away branch above has already claimed;
+ // a copy parked on a stream that gets closed comes back with
+ // ErrStreamClosed from a Write (stream.go:157-159) or the bare
+ // ErrStreamReset from a Read, which is what CloseRead installs
+ // (stream.go:348-349).
+ if err == yamux.ErrStreamClosed || err == yamux.ErrStreamReset {
+ return true, false
+ }
+ // The same sentinel WRAPPED means something else entirely: Session.close
+ // gives every stream it kills ErrStreamReset wrapped around the cause, so
+ // this is the session dying under a live stream. Identity above is what
+ // separates the two; errors.Is cannot.
+ //
+ // Wrapped is not the only way a dead session shows up, though. close()
+ // publishes shutdownErr and closes shutdownCh before it force-closes the
+ // streams, so a Write or Close landing in that window gets the raw cause
+ // back instead (session.go:305-308, 528-533). That form is unrecognisable
+ // as yamux at all, and is why it is left unrecognised here rather than
+ // guessed at: normalizeStreamErr no longer forgives a bare io.EOF, because
+ // for a peer that vanished the raw cause is precisely io.EOF.
+ if errors.Is(err, yamux.ErrStreamReset) {
+ return true, true
+ }
+ return false, false
+}
diff --git a/core/services/cluster/splice_test.go b/core/services/cluster/splice_test.go
new file mode 100644
index 000000000000..7b1001cd8601
--- /dev/null
+++ b/core/services/cluster/splice_test.go
@@ -0,0 +1,425 @@
+package cluster_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/libp2p/go-yamux/v5"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Splice", func() {
+ // pipePair returns two connected in-memory conns.
+ newPair := func() (net.Conn, net.Conn) { return net.Pipe() }
+
+ It("copies bytes in both directions", func() {
+ aLeft, aRight := newPair()
+ bLeft, bRight := newPair()
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(aRight, bLeft) }()
+
+ go func() {
+ _, _ = aLeft.Write([]byte("ping"))
+ }()
+ buf := make([]byte, 4)
+ Expect(bRight.SetReadDeadline(time.Now().Add(5 * time.Second))).To(Succeed())
+ _, err := io.ReadFull(bRight, buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("ping"))
+
+ go func() {
+ _, _ = bRight.Write([]byte("pong"))
+ }()
+ Expect(aLeft.SetReadDeadline(time.Now().Add(5 * time.Second))).To(Succeed())
+ _, err = io.ReadFull(aLeft, buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("pong"))
+
+ Expect(aLeft.Close()).To(Succeed())
+ Eventually(done, "5s").Should(Receive())
+ })
+
+ It("returns when one side closes, and closes the other", func() {
+ aLeft, aRight := newPair()
+ bLeft, bRight := newPair()
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(aRight, bLeft) }()
+
+ Expect(aLeft.Close()).To(Succeed())
+ Eventually(done, "5s").Should(Receive(BeNil()))
+
+ // The far side must have been closed too, so a read there fails
+ // rather than blocking forever. The read runs in a goroutine and is
+ // polled instead of carrying a read deadline: net.Pipe refuses to set
+ // a deadline once *either* end is closed, so a deadline here would
+ // fail exactly when Splice did its job.
+ reads := make(chan error, 1)
+ go func() {
+ _, err := bRight.Read(make([]byte, 1))
+ reads <- err
+ }()
+ var err error
+ Eventually(reads, "5s").Should(Receive(&err))
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, io.EOF)).To(BeTrue())
+ })
+
+ It("returns when both sides close", func() {
+ aLeft, aRight := newPair()
+ bLeft, bRight := newPair()
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(aRight, bLeft) }()
+
+ Expect(aLeft.Close()).To(Succeed())
+ Expect(bRight.Close()).To(Succeed())
+ Eventually(done, "5s").Should(Receive())
+ })
+
+ // The three specs above only ever tear down an idle splice: at the moment
+ // of Close no copy is parked inside a Write. A relayed inference response
+ // is the opposite case, a reader that walks away mid-body while 50MB is
+ // still being pushed at it, so this covers the direction that is blocked
+ // in Write rather than in Read when its peer disappears.
+ It("returns when the reader disappears while a write is in flight", func() {
+ aLeft, aRight := newPair()
+ bLeft, bRight := newPair()
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(aRight, bLeft) }()
+
+ // Nothing ever reads from bRight, so the a->b direction parks inside
+ // Write on an unbuffered pipe with the payload half-delivered.
+ payload := make([]byte, 1<<20)
+ writes := make(chan error, 1)
+ go func() {
+ _, err := aLeft.Write(payload)
+ writes <- err
+ }()
+
+ Expect(bRight.Close()).To(Succeed())
+ Eventually(done, "5s").Should(Receive())
+
+ // The abandoned writer must be released as well, and only Splice
+ // closing its end can do that: no deadline is set on aLeft, so a
+ // splice that forgot to close would leave this write parked forever.
+ Eventually(writes, "5s").Should(Receive(HaveOccurred()))
+ })
+ // net.Pipe can only ever end in EOF or a closed pipe, so the error half of
+ // the contract needs a stream that can be told how to fail.
+ Context("when a stream fails rather than closing", func() {
+ errBoom := errors.New("transport exploded")
+
+ It("reports a genuine transport error", func() {
+ failing := &scriptedStream{readErr: errBoom}
+ idle := &scriptedStream{}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(failing, idle) }()
+
+ var err error
+ Eventually(done, "5s").Should(Receive(&err))
+ Expect(errors.Is(err, errBoom)).To(BeTrue())
+
+ // Closed exactly once each: a second Close is what makes a yamux
+ // stream complain about a teardown that went fine.
+ Expect(failing.closes()).To(Equal(int32(1)))
+ Expect(idle.closes()).To(Equal(int32(1)))
+ })
+
+ It("reports a genuine failure from its own Close", func() {
+ failing := &scriptedStream{closeErr: errBoom}
+ idle := &scriptedStream{}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(idle, failing) }()
+
+ Expect(idle.Close()).To(Succeed())
+ var err error
+ Eventually(done, "5s").Should(Receive(&err))
+ Expect(errors.Is(err, errBoom)).To(BeTrue())
+ })
+
+ // Every one of these means "a stream we were copying through was
+ // closed". The yamux entries are the teardown Splice itself provokes:
+ // none of them matches net.ErrClosed, so each has to be classified by
+ // name or a normal relayed request ends up reported as a failure.
+ DescribeTable("treats a closed stream as normal termination",
+ func(ending error) {
+ ended := &scriptedStream{readErr: ending}
+ idle := &scriptedStream{}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(ended, idle) }()
+
+ Eventually(done, "5s").Should(Receive(BeNil()))
+ },
+ Entry("EOF", io.EOF),
+ Entry("a closed socket", net.ErrClosed),
+ Entry("a closed in-memory pipe", io.ErrClosedPipe),
+ Entry("a closed yamux stream", yamux.ErrStreamClosed),
+ Entry("a reset yamux stream", yamux.ErrStreamReset),
+ Entry("a shut-down yamux session", yamux.ErrSessionShutdown),
+ // The LOCAL forms of the two endings the relay settled below. They
+ // stay normal because they are the teardown this side asked for,
+ // and Remote is the only thing separating them from the endings a
+ // peer inflicts.
+ Entry("a stream this side reset", &yamux.StreamError{ErrorCode: 0, Remote: false}),
+ Entry("a go-away this side sent", &yamux.GoAwayError{ErrorCode: 0, Remote: false}),
+ )
+
+ // sessionDeath is the exact shape Session.close hands every live
+ // stream when the session dies for a non-go-away reason
+ // (session.go:330). It matters that these are wrapped: the cause it
+ // carries is routinely io.EOF or a closed socket, so a classifier that
+ // looked at the cause would call a vanished peer a clean ending.
+ sessionDeath := func(cause error) error {
+ return fmt.Errorf("%w: connection closed: %w", yamux.ErrStreamReset, cause)
+ }
+
+ // A dead peer under a relayed inference request has to reach the
+ // caller. If it arrives as nil, a failed request looks like a finished
+ // one and nothing upstream retries or logs it.
+ DescribeTable("reports the session dying under a stream",
+ func(ending error) {
+ dead := &scriptedStream{readErr: ending}
+ idle := &scriptedStream{}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(dead, idle) }()
+
+ var err error
+ Eventually(done, "5s").Should(Receive(&err))
+ Expect(err).To(MatchError(ending))
+ },
+ Entry("a keepalive timeout", sessionDeath(yamux.ErrKeepAliveTimeout)),
+ Entry("a broken connection", sessionDeath(errors.New("read tcp 10.0.0.1:4000: broken pipe"))),
+ Entry("a peer that vanished", sessionDeath(io.EOF)),
+ Entry("a protocol-error go-away", &yamux.GoAwayError{Remote: true, ErrorCode: 1}),
+ Entry("an internal-error go-away", &yamux.GoAwayError{Remote: true, ErrorCode: 2}),
+ // The two endings phase 1 left open and the relay, Splice's first
+ // production caller, settled as failures. Both truncate whatever
+ // was in flight, and reporting them as normal termination is how a
+ // half-finished inference comes to look like a short one that
+ // completed. See isMuxFailure for why the decision could not be
+ // left to a caller reading Splice's result.
+ Entry("a stream the peer reset", &yamux.StreamError{ErrorCode: 1, Remote: true}),
+ Entry("a graceful go-away from the peer", yamux.ErrRemoteGoAway),
+ )
+
+ // A bare io.EOF can only reach Splice from a failing Write. io.Copy
+ // never surfaces a clean read-side EOF, and yamux hands out the raw
+ // cause rather than the wrapped one when a Write or Close races
+ // Session.close's shutdown window (session.go:507-510), so for a
+ // vanished peer this IS the dead session, arriving unwrapped.
+ It("reports a write that fails with a bare EOF", func() {
+ sink := &scriptedStream{writeErr: io.EOF}
+ source := &scriptedStream{feeds: true}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(sink, source) }()
+
+ var err error
+ Eventually(done, "5s").Should(Receive(&err))
+ Expect(err).To(MatchError(io.EOF))
+ })
+
+ // The distinction the classifier turns on, in one spec: yamux uses the
+ // same sentinel for "this stream was reset", which Splice provokes
+ // itself and must stay quiet about, and as the head of the wrapped
+ // error meaning "the session died", which it must report. Only
+ // identity separates them.
+ It("separates a bare reset from a session that died wrapping one", func() {
+ spliceEnding := func(ending error) error {
+ done := make(chan error, 1)
+ go func() {
+ done <- cluster.Splice(&scriptedStream{readErr: ending}, &scriptedStream{})
+ }()
+ var err error
+ EventuallyWithOffset(1, done, "5s").Should(Receive(&err))
+ return err
+ }
+
+ Expect(spliceEnding(yamux.ErrStreamReset)).To(BeNil())
+ Expect(spliceEnding(sessionDeath(yamux.ErrKeepAliveTimeout))).ToNot(BeNil())
+ })
+
+ // Session death also arrives through the Close Splice makes itself, on
+ // a stream whose session died while the other side was finishing. That
+ // is not the quiet teardown ErrSessionShutdown describes.
+ It("reports a session that died, even from its own Close", func() {
+ stream := &scriptedStream{closeErr: sessionDeath(yamux.ErrKeepAliveTimeout)}
+ backend := &scriptedStream{readErr: io.EOF}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(stream, backend) }()
+
+ var err error
+ Eventually(done, "5s").Should(Receive(&err))
+ Expect(err).To(MatchError(yamux.ErrKeepAliveTimeout))
+ })
+
+ // The tunnel's own teardown: the local backend finishes normally while
+ // the yamux session has already gone away, so the FIN that Splice's
+ // Close writes fails. Nothing went wrong and nothing may be reported.
+ It("does not report a shut-down session on its own Close", func() {
+ stream := &scriptedStream{closeErr: yamux.ErrSessionShutdown}
+ backend := &scriptedStream{readErr: io.EOF}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(stream, backend) }()
+
+ Eventually(done, "5s").Should(Receive(BeNil()))
+ })
+
+ // Everything above feeds Splice a synthesized error. This one drives a
+ // real yamux session, because the shapes a live library produces are
+ // not always the ones its source suggests: the bug this spec was added
+ // alongside was a race inside Session.close that no synthesized error
+ // could show. It asserts only that a dead session is reported, not how
+ // it is spelled, since which of the two forms arrives is a race.
+ It("reports a real yamux session dying under a live stream", func() {
+ clientConn, serverConn := net.Pipe()
+ client, err := yamux.Client(clientConn, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ server, err := yamux.Server(serverConn, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = client.Close()
+ _ = server.Close()
+ })
+
+ accepted := make(chan *yamux.Stream, 1)
+ go func() {
+ defer GinkgoRecover()
+ far, err := server.AcceptStream()
+ if err != nil {
+ close(accepted)
+ return
+ }
+ accepted <- far
+ }()
+
+ stream, err := client.OpenStream(context.Background())
+ Expect(err).ToNot(HaveOccurred())
+ // Push a byte so the stream is established on both sides before
+ // the session is killed.
+ _, err = stream.Write([]byte("x"))
+ Expect(err).ToNot(HaveOccurred())
+ var far *yamux.Stream
+ Eventually(accepted, "10s").Should(Receive(&far))
+ // Deadline so a stream that never carries the byte fails this spec
+ // instead of parking the suite until its own timeout.
+ Expect(far.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+ _, err = far.Read(make([]byte, 1))
+ Expect(err).ToNot(HaveOccurred())
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(stream, &scriptedStream{}) }()
+
+ // The peer's process disappears: the connection carrying the
+ // session goes away, which kills every stream riding on it.
+ Expect(serverConn.Close()).To(Succeed())
+
+ var spliceErr error
+ Eventually(done, "10s").Should(Receive(&spliceErr))
+ Expect(spliceErr).To(HaveOccurred())
+ })
+
+ // The anti-leak guarantee, which the pipe specs cannot see because
+ // their parked copy is released too quickly to catch Splice in the
+ // act. Waking a copy is asynchronous on a real stream (yamux's Close
+ // notifies the reader, which then has to be scheduled), so this stream
+ // splits the two: Close records itself, and the spec decides when the
+ // parked Read actually returns.
+ It("does not return until the second direction has finished", func() {
+ parked := &scriptedStream{holdReadPastClose: true}
+ ending := &scriptedStream{readErr: io.EOF}
+
+ done := make(chan error, 1)
+ go func() { done <- cluster.Splice(ending, parked) }()
+
+ Eventually(parked.closes, "5s").Should(Equal(int32(1)))
+ Consistently(done, "200ms").ShouldNot(Receive())
+
+ parked.release()
+ Eventually(done, "5s").Should(Receive(BeNil()))
+ })
+ })
+})
+
+// scriptedStream is an io.ReadWriteCloser whose endings the spec dictates, so
+// Splice can be fed failures no in-memory pipe can produce. With no readErr it
+// parks in Read until Close, standing in for an idle half of a live stream.
+type scriptedStream struct {
+ readErr error
+ writeErr error
+ closeErr error
+ // feeds makes Read produce bytes instead of parking, so a spec can keep a
+ // direction copying until its destination fails.
+ feeds bool
+ // holdReadPastClose keeps a parked Read blocked until release is called,
+ // standing in for the gap between a Close waking a reader and that reader
+ // running. Without it, Close releases the Read as a real stream does.
+ holdReadPastClose bool
+
+ releaseOnce sync.Once
+ released chan struct{}
+ initOnce sync.Once
+ closeN atomic.Int32
+}
+
+func (s *scriptedStream) gate() chan struct{} {
+ s.initOnce.Do(func() { s.released = make(chan struct{}) })
+ return s.released
+}
+
+func (s *scriptedStream) release() {
+ gate := s.gate()
+ s.releaseOnce.Do(func() { close(gate) })
+}
+
+func (s *scriptedStream) Read(p []byte) (int, error) {
+ if s.readErr != nil {
+ return 0, s.readErr
+ }
+ if s.feeds {
+ select {
+ case <-s.gate():
+ return 0, io.EOF
+ default:
+ return len(p), nil
+ }
+ }
+ <-s.gate()
+ return 0, io.EOF
+}
+
+func (s *scriptedStream) Write(p []byte) (int, error) {
+ if s.writeErr != nil {
+ return 0, s.writeErr
+ }
+ return len(p), nil
+}
+
+func (s *scriptedStream) Close() error {
+ s.closeN.Add(1)
+ if !s.holdReadPastClose {
+ s.release()
+ }
+ return s.closeErr
+}
+
+func (s *scriptedStream) closes() int32 { return s.closeN.Load() }
diff --git a/core/services/cluster/tunnel.go b/core/services/cluster/tunnel.go
new file mode 100644
index 000000000000..ccd8b9cf91df
--- /dev/null
+++ b/core/services/cluster/tunnel.go
@@ -0,0 +1,471 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/libp2p/go-yamux/v5"
+ "github.com/mudler/xlog"
+)
+
+// ConnectPath is the route a worker dials to open its tunnel, and the route the
+// HTTP layer registers the handler on. It lives here, beside the registry that
+// holds what the dial produces, for the reason PeerPath does: the HTTP
+// endpoints package imports this one, never the other way round.
+//
+// The literal is spelled out rather than derived from auth.ClusterPathPrefix
+// because importing core/http/auth is exactly the dependency this package must
+// not have. A spec in the endpoints package, which can see both, holds the two
+// from drifting apart.
+const ConnectPath = "/api/cluster/connect"
+
+// ErrNotOwner reports that this replica does not hold the tunnel for a node.
+//
+// It is a ROUTING fact and nothing else: some other replica may hold that
+// worker perfectly well, and a caller that sees it relays through the owner the
+// database names. It must therefore never be produced by anything that merely
+// failed. A database error, a broken socket, a session that shut down under a
+// held entry: each of those is reported as itself, because reporting them as
+// "not held here" tells a dialer to look elsewhere for a worker that is right
+// here, and the design forbids absence standing in for unreachable. This is the
+// same rule Claim and Owner follow when they refuse a dialect rather than
+// answering ErrNoConnection.
+var ErrNotOwner = errors.New("cluster: this replica does not hold the tunnel for that node")
+
+// tunnelReleaseTimeout bounds the release Detach performs. Detach is called
+// from the goroutine that has just watched a worker's session die, and that
+// goroutine must not be parked on a database that went away with it.
+const tunnelReleaseTimeout = 5 * time.Second
+
+// TunnelRegistry holds the worker tunnels this replica has accepted, and keeps
+// the node_connections table agreeing with what it holds.
+//
+// It is the local half of the connection fence: the table says which replica
+// owns a worker, and this says which socket that ownership actually resolves
+// to. The two are written in one order, always, by Attach.
+type TunnelRegistry struct {
+ reg *Registry
+ selfID string
+
+ mu sync.Mutex
+ tunnels map[string]*heldTunnel
+ // claiming holds one gate per node that a claim is in flight for. It is
+ // what makes "claim, then record the epoch" indivisible per node; see
+ // enterClaim.
+ claiming map[string]chan struct{}
+}
+
+// heldTunnel is one accepted worker tunnel.
+//
+// The two epochs are the same number until this replica is swept and re-claims,
+// and they are separate fields because they answer different questions.
+//
+// token is what Attach handed back, and it is the only value Detach matches.
+// It identifies one local attachment for that attachment's whole life, which is
+// what lets a superseded holder's Detach be recognised as stale: epochs are
+// never reissued, so a token from an earlier attachment cannot collide with a
+// later one's.
+//
+// claim is the epoch of the row this replica currently holds for the node, and
+// it is what Release must be given, because that is the row the fence matches
+// on. A re-claim draws a fresh epoch and moves this one; leaving Release to use
+// the token instead would match nothing, and the row would outlive the socket
+// with no caller able to tell.
+//
+// Neither is ever ordered against the other, or against anything else. Claim
+// guarantees uniqueness, not monotonicity.
+type heldTunnel struct {
+ sess *yamux.Session
+ token int64
+ claim int64
+}
+
+// NewTunnelRegistry returns a registry that claims tunnels as selfID. The ID
+// must be the same one this replica registers in the instances table, since
+// that is what Owner joins a claim against to decide the owner is alive.
+func NewTunnelRegistry(reg *Registry, selfID string) *TunnelRegistry {
+ return &TunnelRegistry{
+ reg: reg,
+ selfID: selfID,
+ tunnels: map[string]*heldTunnel{},
+ claiming: map[string]chan struct{}{},
+ }
+}
+
+// enterClaim takes the gate for nodeID, so that no two claims for one node are
+// ever in flight at the same time. leaveClaim releases it.
+//
+// It exists because a claim and the record of that claim are two steps, and
+// between them the database has already moved. Two Attach calls for one node
+// both claim, and PostgreSQL serialises the two upserts, but nothing orders the
+// two map writes against the two commits: the entry that ends up installed can
+// carry the epoch of the claim that did NOT win the row. Its Detach then
+// releases an epoch the row does not hold, the release matches nothing, and the
+// row survives the socket. Nothing sweeps that, because the replica named on it
+// is alive and heartbeating, so Owner keeps naming this replica as the owner of
+// a tunnel it no longer holds and every dialer routed here gets ErrNotOwner.
+//
+// The gate is per node rather than one lock over the whole registry so that a
+// slow claim for one worker does not hold up Open for any other, the same
+// reason PeerPool locks per peer. Detach is deliberately NOT gated: it takes no
+// context and must never park behind an in-flight database call. It does not
+// need to be, because it changes no epoch; what it can interleave with is
+// covered where that matters, in Reclaim.
+//
+// The entry is deleted rather than kept, so the map holds only the claims
+// actually in flight and cannot grow with the number of workers ever seen.
+func (t *TunnelRegistry) enterClaim(ctx context.Context, nodeID string) error {
+ for {
+ t.mu.Lock()
+ gate, busy := t.claiming[nodeID]
+ if !busy {
+ t.claiming[nodeID] = make(chan struct{})
+ t.mu.Unlock()
+ return nil
+ }
+ t.mu.Unlock()
+
+ // Re-checked in the loop rather than taken on waking: several waiters
+ // are released by one close, and only one of them may proceed.
+ select {
+ case <-gate:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+// leaveClaim releases the gate enterClaim took. The channel is closed rather
+// than sent on, so every waiter wakes rather than one.
+func (t *TunnelRegistry) leaveClaim(nodeID string) {
+ t.mu.Lock()
+ gate := t.claiming[nodeID]
+ delete(t.claiming, nodeID)
+ t.mu.Unlock()
+ close(gate)
+}
+
+// Attach records this replica as the owner of nodeID's tunnel and stores the
+// session, returning the epoch the caller must later hand to Detach.
+//
+// The claim is written BEFORE the session is stored, and the order is the
+// point. A claimant that installs itself first and only then finds it cannot
+// claim has, for that window, published a tunnel no row records: Held names it,
+// and a peer asking Owner is told the worker is connected nowhere. Claiming
+// first means a failed claim leaves this replica exactly as it was.
+//
+// A worker that re-dials onto this same replica supersedes its own earlier
+// attachment, and the superseded session is closed here. Nothing else can close
+// it: whoever accepted it is parked in AcceptStream on a session that is not
+// broken, only replaced, and would wait there until the far side noticed. The
+// caller keeps ownership of the session it passed in; only a session this
+// registry evicted is closed by this registry.
+//
+// Two Attach calls for one node are serialised, claim and record together, so
+// the entry that survives is always the one whose claim the row carries. See
+// enterClaim for what an unserialised pair leaves behind.
+func (t *TunnelRegistry) Attach(ctx context.Context, nodeID string, sess *yamux.Session) (int64, error) {
+ if sess == nil {
+ // Claiming would publish a tunnel that cannot carry anything, and the
+ // fence would then have to be unwound by a Detach nobody will call.
+ return 0, fmt.Errorf("attaching tunnel for node %q: no session", nodeID)
+ }
+
+ if err := t.enterClaim(ctx, nodeID); err != nil {
+ return 0, fmt.Errorf("attaching tunnel for node %q: %w", nodeID, err)
+ }
+
+ // The gated part is a closure so its release can be DEFERRED while the
+ // session close below still happens outside the gate. Releasing on each
+ // return path instead leaves one way out uncovered: a panic. Claim does
+ // database work, and a panic anywhere under it would leave this node's gate
+ // closed for the life of the process, so every later Attach or Reclaim for
+ // that worker would block in enterClaim until its own context expired. The
+ // caller's recover would report the panic and the worker would look
+ // permanently unable to reconnect, with nothing linking the two.
+ var previous *heldTunnel
+ epoch, err := func() (int64, error) {
+ defer t.leaveClaim(nodeID)
+
+ epoch, err := t.reg.Claim(ctx, nodeID, t.selfID)
+ if err != nil {
+ return 0, err
+ }
+
+ t.mu.Lock()
+ previous = t.tunnels[nodeID]
+ t.tunnels[nodeID] = &heldTunnel{sess: sess, token: epoch, claim: epoch}
+ t.mu.Unlock()
+ return epoch, nil
+ }()
+ if err != nil {
+ return 0, err
+ }
+
+ // Closed after the gate is released, not under it. The gate is justified by
+ // being held for one claim round trip, and closing a session is not that:
+ // yamux closes the underlying conn and then waits for both its send and
+ // recv loops to exit (go-yamux/v5@v5.1.0/session.go:330-332), and the send
+ // loop can be inside a write bounded only by ConnectionWriteTimeout. That
+ // is a wait on other goroutines, and it must not stand between a worker
+ // re-dialling this node and its claim.
+ //
+ // Releasing first is safe because the superseded session is no longer
+ // reachable from the map: whoever re-dials next replaces an entry that
+ // already names the new session, and this close can only ever affect the
+ // one it just displaced.
+ if previous != nil && previous.sess != sess {
+ xlog.Debug("worker re-dialled this replica, dropping its previous tunnel", "node", nodeID)
+ _ = previous.sess.Close()
+ }
+ return epoch, nil
+}
+
+// Detach drops the attachment epoch identifies and releases its claim. An epoch
+// that is not the one Attach handed the current holder is a no-op, which is how
+// a superseded holder noticing its dead socket is stopped from evicting the
+// attachment that replaced it.
+//
+// Matched by EQUALITY, never by order. An epoch is unique and never reissued,
+// but a claim inserted after a Release can draw a lower number than one already
+// issued, so a stale token may compare either way against the live one.
+//
+// Releasing a claim this replica no longer holds is ordinary rather than
+// exceptional: it is what a worker having re-homed to another replica looks
+// like from here, so it is logged and not returned. Detach has no error to
+// return to, being the last thing a dying tunnel's goroutine does.
+func (t *TunnelRegistry) Detach(nodeID string, epoch int64) {
+ t.mu.Lock()
+ held, ok := t.tunnels[nodeID]
+ if !ok || held.token != epoch {
+ t.mu.Unlock()
+ return
+ }
+ delete(t.tunnels, nodeID)
+ claim := held.claim
+ t.mu.Unlock()
+
+ // Not the caller's context, and not the one Attach was given: both belong
+ // to the request or the process that set the tunnel up, and by the time a
+ // tunnel is being torn down either may already be cancelled, which would
+ // leave the row behind on every ordinary disconnect.
+ ctx, cancel := context.WithTimeout(context.Background(), tunnelReleaseTimeout)
+ defer cancel()
+ // The claim, not the token: the row carries whatever epoch was last claimed
+ // for this attachment, and Release matches the row exactly.
+ if err := t.reg.Release(ctx, nodeID, t.selfID, claim); err != nil {
+ if errors.Is(err, ErrNoConnection) {
+ xlog.Debug("worker tunnel claim was already superseded", "node", nodeID, "epoch", claim)
+ return
+ }
+ xlog.Warn("Releasing a worker tunnel claim failed; peers will drop it when this replica's heartbeat ages out",
+ "node", nodeID, "epoch", claim, "error", err)
+ }
+}
+
+// Open returns a stream to the worker over the tunnel this replica holds.
+//
+// ErrNotOwner means only that no tunnel for nodeID is held here. Every other
+// failure is returned as itself, wrapped: a session that died under a held
+// entry is a transport condition, and answering ErrNotOwner for it would send a
+// dialer looking elsewhere for a worker this replica is holding.
+//
+// A failed open does not evict the entry. Whether a tunnel is held here is
+// decided by Attach and Detach, and letting one bad open unhold it would race
+// the goroutine that owns the session and is about to detach it properly.
+func (t *TunnelRegistry) Open(ctx context.Context, nodeID string) (net.Conn, error) {
+ t.mu.Lock()
+ held, ok := t.tunnels[nodeID]
+ t.mu.Unlock()
+ if !ok {
+ return nil, fmt.Errorf("opening a stream to node %q: %w", nodeID, ErrNotOwner)
+ }
+
+ stream, err := held.sess.OpenStream(ctx)
+ if err != nil {
+ // The same rule peerlink.go applies to a peer, applied here to a
+ // tunnel, because the confusion is the same one: a caller whose own
+ // budget ran out gets the socket's error back before the context's
+ // cancel func has necessarily run, so ctx.Err() can still read nil
+ // while the failure is entirely the caller's. Reporting it plainly
+ // would put "the tunnel this replica holds would not carry a stream"
+ // in an operator's log for a worker that is fine and a client that was
+ // impatient. callerRanOut settles it on the wall clock; see its
+ // comment for why ctx.Err() alone is not the question.
+ //
+ // The caller's error is wrapped rather than returned bare, so
+ // WorkerDialer's contract still holds (every failure to resolve or open
+ // carries ErrNoRoute) and context.DeadlineExceeded stays matchable
+ // underneath for anyone that wants to tell the two apart.
+ if ctxErr := callerRanOut(ctx); ctxErr != nil {
+ return nil, fmt.Errorf("opening a stream to node %q over the tunnel held here: the caller's own budget ran out: %w", nodeID, ctxErr)
+ }
+ return nil, fmt.Errorf("opening a stream to node %q over the tunnel held here: %w", nodeID, err)
+ }
+ return stream, nil
+}
+
+// Held returns the nodes whose tunnels this replica holds, sorted.
+//
+// It answers what this process holds, which is not the same question as who the
+// table says owns a node; Owner answers that one. The membership loop uses this
+// to know what to re-claim after its rows have been swept.
+func (t *TunnelRegistry) Held() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ out := make([]string, 0, len(t.tunnels))
+ for nodeID := range t.tunnels {
+ out = append(out, nodeID)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Reclaim writes a fresh claim for every tunnel still held here, and returns
+// how many it wrote.
+//
+// It exists for one case: this replica stalled long enough for a peer to sweep
+// it, which deleted its instance row AND every connection row it owned, and it
+// has just re-registered. Re-registration rebuilds the instance row only, so
+// without this the sockets are still held here while the table records nobody
+// holding them, and every other replica answers "not connected" for workers
+// that are connected.
+//
+// A closed session is skipped rather than claimed. Claiming is an upsert, so it
+// takes the row from whoever holds it now, and a worker whose socket here is
+// closed has already reconnected somewhere: claiming it back would point every
+// dialer at a replica that cannot carry a byte to it. The check narrows that
+// window rather than closing it, since a socket can be dead without this side
+// having noticed, and how long that lasts is decided by the keepalive on the
+// session whoever accepted the tunnel built. The worker's next reconnect
+// supersedes the claim in any case.
+//
+// The entry of a skipped tunnel is left alone. Whoever attached it owns its
+// lifetime and will detach it; Reclaim is not an eviction path, and evicting
+// here would race that goroutine.
+//
+// A single node's failure does not abort the rest: the tunnels are independent,
+// and a claim that failed is retried on the next sweep this replica survives.
+func (t *TunnelRegistry) Reclaim(ctx context.Context) (int, error) {
+ t.mu.Lock()
+ held := make([]string, 0, len(t.tunnels))
+ for nodeID := range t.tunnels {
+ held = append(held, nodeID)
+ }
+ t.mu.Unlock()
+
+ var reclaimed int
+ var errs []error
+ for _, nodeID := range held {
+ if err := t.reclaimOne(ctx, nodeID); err != nil {
+ if errors.Is(err, errTunnelNotReclaimed) {
+ continue
+ }
+ errs = append(errs, err)
+ continue
+ }
+ reclaimed++
+ }
+ if len(errs) > 0 {
+ return reclaimed, fmt.Errorf("re-claiming worker tunnels: %w", errors.Join(errs...))
+ }
+ return reclaimed, nil
+}
+
+// errTunnelNotReclaimed reports that a node was passed over rather than failed:
+// its session is closed, or the attachment went away while the claim was in
+// flight. It never leaves this file. It exists so Reclaim's count stays honest
+// without "skipped" having to look like an error to its caller.
+var errTunnelNotReclaimed = errors.New("cluster: tunnel not re-claimed")
+
+// reclaimOne writes a fresh claim for one node and records it on whatever
+// attachment is installed for that node.
+//
+// Whatever is installed when the gate is taken, not whatever Reclaim listed a
+// moment earlier. A worker that re-dialled in between has an entry carrying the
+// epoch of ITS claim, and the gate makes that claim strictly older than this
+// one, so the row now holds this epoch and only this entry can release it.
+// Refusing to record onto an attachment because it is not the one listed would
+// leave that row with no attachment able to release it, which is the leak this
+// whole function exists to prevent.
+//
+// If nothing is installed, the attachment detached while the claim was in
+// flight. Detach is not gated, so this is reachable, and it is the one case
+// where a claim is drawn that no attachment will ever release: the row would
+// name this replica for a tunnel it does not hold, and Owner would send every
+// dialer here to be told ErrNotOwner. The claim is therefore released again.
+// Releasing it cannot take anyone else's row, because Release matches the epoch
+// exactly and no epoch is ever reissued.
+func (t *TunnelRegistry) reclaimOne(ctx context.Context, nodeID string) error {
+ // The gate is taken before the entry is even read, so that everything this
+ // function decides is decided about the attachment its claim will land on.
+ // Reading first and gating after would leave a window in which a re-dial
+ // replaces the entry, and the liveness this checked would be a property of
+ // a session it is no longer claiming for.
+ if err := t.enterClaim(ctx, nodeID); err != nil {
+ return fmt.Errorf("re-claiming node %q: %w", nodeID, err)
+ }
+
+ // Gated part in a closure so the release is DEFERRED, for the reason Attach
+ // gives: a panic under Claim would otherwise wedge this node's gate for the
+ // life of the process. The trailing Release still runs outside the gate.
+ var epoch int64
+ var installed bool
+ if err := func() error {
+ defer t.leaveClaim(nodeID)
+
+ t.mu.Lock()
+ tunnel, ok := t.tunnels[nodeID]
+ t.mu.Unlock()
+ if !ok {
+ // Detached between Reclaim listing the nodes and this gate. Nothing
+ // was claimed, so there is nothing to undo.
+ return errTunnelNotReclaimed
+ }
+ if tunnel.sess.IsClosed() {
+ xlog.Debug("skipping re-claim of a worker tunnel whose session is closed", "node", nodeID)
+ return errTunnelNotReclaimed
+ }
+
+ var err error
+ epoch, err = t.reg.Claim(ctx, nodeID, t.selfID)
+ if err != nil {
+ return err
+ }
+
+ t.mu.Lock()
+ current, present := t.tunnels[nodeID]
+ installed = present
+ if installed {
+ // current is necessarily the entry read above: the gate is still
+ // held, and Attach and reclaimOne are the only writers that install
+ // one. The identity is therefore not re-checked; the case that IS
+ // reachable is the entry being gone, because Detach is not gated.
+ current.claim = epoch
+ }
+ t.mu.Unlock()
+ return nil
+ }(); err != nil {
+ return err
+ }
+
+ if installed {
+ return nil
+ }
+
+ // Released outside the gate: it is a second round trip, and holding the
+ // gate across it would park a worker re-dialling this node behind a
+ // cleanup. A re-dial that claims first simply makes this release match
+ // nothing, which is the same no-op it would have been.
+ if err := t.reg.Release(ctx, nodeID, t.selfID, epoch); err != nil && !errors.Is(err, ErrNoConnection) {
+ return fmt.Errorf("releasing a re-claim for detached node %q: %w", nodeID, err)
+ }
+ return errTunnelNotReclaimed
+}
diff --git a/core/services/cluster/tunnel_test.go b/core/services/cluster/tunnel_test.go
new file mode 100644
index 000000000000..26d70350a1df
--- /dev/null
+++ b/core/services/cluster/tunnel_test.go
@@ -0,0 +1,872 @@
+package cluster_test
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+)
+
+// claimHook runs an action once, from inside the database call that issued a
+// matching statement, on that call's own goroutine.
+//
+// It is how a spec pins an interleaving instead of racing for one. gorm calls
+// its logger's Trace after the statement has executed and before the Create or
+// Delete that issued it returns (gorm@v1.31.1/callbacks.go:139-145), with the
+// bind values interpolated into the SQL, so an action installed here runs at
+// the one instant a claim has been written and not yet recorded. Racing two
+// goroutines and hoping to land in that window is the flaky spec this replaces.
+//
+// It fires at most once: the action itself issues statements through the same
+// session, and an unguarded hook would recurse.
+type claimHook struct {
+ gormlogger.Interface
+ mu sync.Mutex
+ fired bool
+ match func(sql string) bool
+ action func(sql string)
+}
+
+func newClaimHook(match func(sql string) bool) *claimHook {
+ return &claimHook{Interface: gormlogger.Default.LogMode(gormlogger.Silent), match: match}
+}
+
+// setAction installs what the hook runs, under the same lock that guards fired.
+// The action is written from the spec's goroutine and read from whichever
+// goroutine issues the statement, which need not be the same one.
+func (h *claimHook) setAction(action func(sql string)) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.action = action
+}
+
+func (h *claimHook) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
+ sql, rows := fc()
+ h.mu.Lock()
+ action := h.action
+ fire := !h.fired && action != nil && h.match(sql)
+ if fire {
+ h.fired = true
+ }
+ h.mu.Unlock()
+ if fire {
+ action(sql)
+ }
+ h.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
+}
+
+// isClaimOf matches the upsert Claim issues for one node.
+func isClaimOf(nodeIDs ...string) func(string) bool {
+ return func(sql string) bool {
+ if !strings.Contains(sql, "INSERT INTO \"node_connections\"") {
+ return false
+ }
+ for _, nodeID := range nodeIDs {
+ if strings.Contains(sql, "'"+nodeID+"'") {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+// claimedNode reports which of the named nodes a claim statement was for.
+func claimedNode(sql string, nodeIDs ...string) string {
+ GinkgoHelper()
+ for _, nodeID := range nodeIDs {
+ if strings.Contains(sql, "'"+nodeID+"'") {
+ return nodeID
+ }
+ }
+ Fail("the claim statement named none of " + strings.Join(nodeIDs, ", "))
+ return ""
+}
+
+// serializationProbe is how long a spec watches for something that must not
+// happen. It bounds an assertion about an ABSENT event, which is the only kind
+// of wait a spec cannot replace with a channel: there is no event to receive.
+// The thing it watches for takes one database round trip when the serialisation
+// it guards is missing, so this is orders of magnitude longer than it needs.
+const serializationProbe = 500 * time.Millisecond
+
+// workerTunnel returns the two halves of a worker's tunnel: the frontend holds
+// the server half, because the worker is the side that dials. yamuxPair already
+// builds exactly that pairing for peer links; this names the halves the way the
+// worker path uses them so a spec cannot silently attach the wrong end.
+func workerTunnel() (frontend *yamux.Session, worker *yamux.Session) {
+ GinkgoHelper()
+ worker, frontend = yamuxPair()
+ return frontend, worker
+}
+
+// echoOnce accepts one stream on the worker's half and echoes what it reads.
+// It is how a spec proves Open produced a stream that carries bytes, rather
+// than a handle that merely exists.
+func echoOnce(worker *yamux.Session) {
+ go func() {
+ defer GinkgoRecover()
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ buf := make([]byte, 4)
+ if _, err := stream.Read(buf); err != nil {
+ return
+ }
+ _, _ = stream.Write(buf)
+ }()
+}
+
+// drain accepts and discards every stream on the worker's half, so a spec that
+// opens streams without reading them does not park on the accept backlog.
+func drain(worker *yamux.Session) {
+ go func() {
+ defer GinkgoRecover()
+ for {
+ stream, err := worker.AcceptStream()
+ if err != nil {
+ return
+ }
+ _ = stream.Close()
+ }
+ }()
+}
+
+var _ = Describe("The worker tunnel registry", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ tun *cluster.TunnelRegistry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ tun = cluster.NewTunnelRegistry(reg, "me")
+ })
+
+ It("claims the node in the database before it stores the session", func() {
+ // A claimant that installs itself and only then tries to claim has,
+ // for that window, made the registry disagree with the table: Held
+ // names a tunnel no row records, and a peer asking Owner is told the
+ // worker is connected nowhere. The failure is injected through the
+ // production refusal path, a dialect with no epoch sequence, so the
+ // spec exercises the real error return rather than a fake.
+ sqliteDB, err := gorm.Open(sqlite.Open(filepath.Join(GinkgoT().TempDir(), "cluster.db")), &gorm.Config{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.Migrate(ctx, sqliteDB)).To(Succeed())
+ unclaimable := cluster.NewTunnelRegistry(cluster.NewRegistry(sqliteDB), "me")
+
+ frontend, _ := workerTunnel()
+ _, err = unclaimable.Attach(ctx, "w1", frontend)
+ Expect(err).To(HaveOccurred())
+
+ Expect(unclaimable.Held()).To(BeEmpty(),
+ "a claimant whose claim failed installed itself anyway, so the registry now disagrees with the table")
+ _, err = unclaimable.Open(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNotOwner))
+ })
+
+ It("records the claim, so another replica can find the owner", func() {
+ frontend, _ := workerTunnel()
+ epoch, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(tun.Held()).To(ConsistOf("w1"))
+ owner, stored, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("me"))
+ Expect(stored).To(Equal(epoch), "Attach handed back an epoch that is not the one it wrote")
+ })
+
+ It("opens a stream that carries bytes to the worker", func() {
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+ echoOnce(worker)
+
+ conn, err := tun.Open(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(conn).ToNot(BeNil())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ Expect(conn.SetDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+ _, err = conn.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ buf := make([]byte, 4)
+ _, err = conn.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("ping"))
+ })
+
+ It("reports ErrNotOwner for a node whose tunnel it does not hold", func() {
+ // This is a routing fact and nothing more: some other replica may hold
+ // the worker perfectly well. It must never be produced by anything that
+ // merely failed.
+ _, err := tun.Open(ctx, "nobody")
+ Expect(err).To(MatchError(cluster.ErrNotOwner))
+ })
+
+ It("supersedes an earlier attachment, and the superseded holder's Detach is a no-op", func() {
+ first, _ := workerTunnel()
+ firstEpoch, err := tun.Attach(ctx, "w1", first)
+ Expect(err).ToNot(HaveOccurred())
+
+ second, secondWorker := workerTunnel()
+ secondEpoch, err := tun.Attach(ctx, "w1", second)
+ Expect(err).ToNot(HaveOccurred())
+ // Compared for difference, never for order. Claim guarantees an epoch
+ // is unique and never reissued; it does NOT guarantee the later claim
+ // draws the larger number, because the sequence value on the insert
+ // path is drawn before the row lock.
+ Expect(secondEpoch).ToNot(Equal(firstEpoch))
+
+ Expect(first.IsClosed()).To(BeTrue(),
+ "the superseded session was left open, so whoever is accepting on it never learns it was replaced")
+
+ tun.Detach("w1", firstEpoch)
+
+ Expect(tun.Held()).To(ConsistOf("w1"), "a stale Detach evicted the live session")
+ owner, stored, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(owner).To(Equal("me"))
+ Expect(stored).To(Equal(secondEpoch), "a stale Detach released the live claim")
+
+ echoOnce(secondWorker)
+ conn, err := tun.Open(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Expect(conn.SetDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+ _, err = conn.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+ buf := make([]byte, 4)
+ _, err = conn.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("ping"))
+
+ tun.Detach("w1", secondEpoch)
+ Expect(tun.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("ignores a Detach naming an epoch it was never handed", func() {
+ frontend, _ := workerTunnel()
+ epoch, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Not an ordering probe: both directions are tried because an epoch is
+ // unique but unordered, so a stale token can compare either way against
+ // the live one and neither may be allowed to evict it.
+ tun.Detach("w1", epoch+1)
+ tun.Detach("w1", epoch-1)
+
+ Expect(tun.Held()).To(ConsistOf("w1"))
+ _, stored, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored).To(Equal(epoch))
+ })
+
+ It("does not report a held tunnel whose session has died as ErrNotOwner", func() {
+ // Absence and unreachability are different answers and callers act
+ // differently on them: a dialer told the worker is not here relays
+ // elsewhere or reports it gone, where the truth is that this replica
+ // holds the tunnel and the socket underneath it broke.
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(worker.Close()).To(Succeed())
+ Eventually(frontend.IsClosed, "10s").Should(BeTrue())
+
+ _, err = tun.Open(ctx, "w1")
+ Expect(err).To(HaveOccurred())
+ Expect(err).ToNot(MatchError(cluster.ErrNotOwner),
+ "a broken socket was reported as this replica not holding the tunnel")
+ Expect(tun.Held()).To(ConsistOf("w1"),
+ "holding the tunnel is a routing fact, and a failed Open is not what un-holds it")
+ })
+
+ It("blames the caller's own spent budget, not the tunnel, when a stream cannot be opened", func() {
+ // The second of the three sites where peerlink.go's callerRanOut rule
+ // has to hold. A caller whose budget ran out gets the multiplexer's
+ // error back before the scheduler has necessarily run its context's
+ // cancel func, so ctx.Err() can still read nil while the failure is
+ // entirely the caller's; reported plainly it becomes "the tunnel this
+ // replica holds would not carry a stream" in an operator's log for a
+ // worker that is fine.
+ //
+ // deadlinePassed is that window made deterministic: deadline elapsed,
+ // cancellation not delivered. Nothing about the broken session is
+ // faked.
+ frontend, worker := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(worker.Close()).To(Succeed())
+ Eventually(frontend.IsClosed, "10s").Should(BeTrue())
+
+ _, err = tun.Open(deadlinePassed{ctx}, "w1")
+ Expect(err).To(HaveOccurred())
+ Expect(err).To(MatchError(context.DeadlineExceeded),
+ "the caller's budget was spent, and only it can say so")
+ Expect(err.Error()).To(ContainSubstring("the caller's own budget ran out"))
+ })
+
+ It("refuses a nil session rather than claiming a tunnel that cannot carry anything", func() {
+ // A claim written for a session that does not exist publishes a tunnel
+ // to every replica in the deployment, and the fence would then have to
+ // be unwound by a Detach nobody is going to call.
+ _, err := tun.Attach(ctx, "w1", nil)
+ Expect(err).To(HaveOccurred())
+
+ Expect(tun.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "a node with no session was published to the deployment as connected here")
+ })
+
+ It("returns the nodes it holds in sorted order", func() {
+ // Attached out of order on purpose: sorted output is what makes a log
+ // line and a re-claim pass comparable between two runs, and a map
+ // range would only look sorted until it did not.
+ for _, nodeID := range []string{"w3", "w1", "w2"} {
+ frontend, _ := workerTunnel()
+ _, err := tun.Attach(ctx, nodeID, frontend)
+ Expect(err).ToNot(HaveOccurred())
+ }
+ Expect(tun.Held()).To(Equal([]string{"w1", "w2", "w3"}))
+ })
+
+ It("serialises two Attach calls for one node, so the surviving entry holds the row's epoch", func() {
+ // Two claims for one node are serialised by PostgreSQL, but nothing
+ // orders the two map writes against the two commits. Unserialised, the
+ // entry left installed can carry the epoch of the claim that lost the
+ // row: its Detach then releases an epoch the row does not hold, the
+ // release matches nothing, and the row outlives the socket. Nothing
+ // sweeps that, because this replica is alive and heartbeating, so Owner
+ // keeps sending dialers here to be told ErrNotOwner.
+ hook := newClaimHook(isClaimOf("w1"))
+ hooked := cluster.NewTunnelRegistry(
+ cluster.NewRegistry(db.Session(&gorm.Session{Logger: hook})), "me")
+
+ secondSession, _ := workerTunnel()
+ secondStarted := make(chan struct{})
+ secondEpochs := make(chan int64, 1)
+ hook.setAction(func(string) {
+ // Launched from inside the first claim, so the second Attach is
+ // provably reaching for the same node while the first is between
+ // its claim and its store. Starting it before the call would leave
+ // which one claims first to the scheduler.
+ go func() {
+ defer GinkgoRecover()
+ close(secondStarted)
+ epoch, err := hooked.Attach(ctx, "w1", secondSession)
+ Expect(err).ToNot(HaveOccurred())
+ secondEpochs <- epoch
+ }()
+ <-secondStarted
+ Consistently(secondEpochs, serializationProbe, 10*time.Millisecond).ShouldNot(Receive(),
+ "a second Attach for this node claimed AND recorded its epoch while the first was between its own claim and store")
+ })
+
+ firstSession, _ := workerTunnel()
+ firstEpoch, err := hooked.Attach(ctx, "w1", firstSession)
+ Expect(err).ToNot(HaveOccurred())
+ var secondEpoch int64
+ Eventually(secondEpochs, "10s").Should(Receive(&secondEpoch))
+ Expect(secondEpoch).ToNot(Equal(firstEpoch))
+
+ _, stored, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect([]int64{firstEpoch, secondEpoch}).To(ContainElement(stored))
+
+ // Whichever attachment survived, one of these two Detach calls is the
+ // live one and must take the row with it. If neither does, the row is
+ // carrying an epoch no attachment holds.
+ hooked.Detach("w1", firstEpoch)
+ hooked.Detach("w1", secondEpoch)
+ Expect(hooked.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "the surviving attachment could not release its row, so the row outlived the socket")
+ })
+
+ It("serves Attach, Open, Held and Detach from independent goroutines", func() {
+ // Run under -race. The point is contention on one node's entry, not a
+ // tidy per-goroutine partition: a registry whose map is only ever
+ // touched by one goroutine at a time proves nothing about the one that
+ // is not.
+ const workers = 8
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+
+ attached := make(chan int64, workers)
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer GinkgoRecover()
+ defer wg.Done()
+ frontend, worker := workerTunnel()
+ drain(worker)
+ <-start
+ epoch, err := tun.Attach(ctx, fmt.Sprintf("w%d", i%2), frontend)
+ Expect(err).ToNot(HaveOccurred())
+ attached <- epoch
+ if conn, err := tun.Open(ctx, fmt.Sprintf("w%d", i%2)); err == nil {
+ _ = conn.Close()
+ }
+ }(i)
+ }
+ readers := make(chan struct{})
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer GinkgoRecover()
+ defer wg.Done()
+ <-start
+ for {
+ select {
+ case <-readers:
+ return
+ default:
+ tun.Held()
+ }
+ }
+ }()
+ }
+
+ close(start)
+ epochs := make([]int64, 0, workers)
+ for i := 0; i < workers; i++ {
+ epochs = append(epochs, <-attached)
+ }
+ close(readers)
+ wg.Wait()
+
+ // Exactly one attachment per node survived, whichever won, and the
+ // table agrees with the map about which.
+ Expect(tun.Held()).To(ConsistOf("w0", "w1"))
+ for _, node := range tun.Held() {
+ _, stored, err := reg.OwnerRow(ctx, node)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(epochs).To(ContainElement(stored),
+ "the table records an epoch no Attach ever handed out")
+ }
+
+ // Every loser's Detach is a no-op; only the two winners empty the map.
+ for _, epoch := range epochs {
+ tun.Detach("w0", epoch)
+ tun.Detach("w1", epoch)
+ }
+ Expect(tun.Held()).To(BeEmpty())
+ for _, node := range []string{"w0", "w1"} {
+ _, _, err := reg.OwnerRow(ctx, node)
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "node %s kept a row no attachment could release", node)
+ }
+ })
+})
+
+var _ = Describe("Re-claiming tunnels after this replica's rows were reaped", func() {
+ var (
+ db *gorm.DB
+ reg *cluster.Registry
+ tun *cluster.TunnelRegistry
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ db = testutil.SetupTestDB()
+ ctx = context.Background()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ reg = cluster.NewRegistry(db)
+ Expect(reg.Register(ctx, "me", "10.0.0.1:8080", "v1")).To(Succeed())
+ tun = cluster.NewTunnelRegistry(reg, "me")
+ })
+
+ // reapSelf performs the two deletes a peer's sweep performs on this
+ // replica: the instance row and, in the same transaction, every connection
+ // it owned. Deregister is that transaction; ReapStale reaches it by aging
+ // last_seen, which cannot be done deterministically against a heartbeat
+ // loop that is refreshing the same column. That ReapStale deletes both is
+ // pinned separately, with no loop running, in the reaping specs.
+ reapSelf := func() {
+ GinkgoHelper()
+ Expect(reg.Deregister(ctx, "me")).To(Succeed())
+ }
+
+ It("re-claims every held tunnel when the loop finds its instance row gone", func() {
+ // Without this a replica that stalled long enough to be swept sits
+ // holding live worker sockets that no row records. Every other replica
+ // then answers "not connected" for workers that are connected, which is
+ // the absence-versus-unreachable failure the design forbids.
+ frontend, _ := workerTunnel()
+ epoch, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ membership.SetTunnels(tun)
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ reapSelf()
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+
+ owner := func() (string, error) {
+ owner, _, err := reg.OwnerRow(ctx, "w1")
+ return owner, err
+ }
+ Eventually(owner, 3*cluster.InstanceHeartbeat, time.Second).Should(Equal("me"))
+
+ _, reclaimed, err := reg.OwnerRow(ctx, "w1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reclaimed).ToNot(Equal(epoch), "the re-claim reused the epoch of a claim the sweep deleted")
+
+ // The holder still carries the epoch Attach handed it, and is the only
+ // thing that will ever release this row. If Detach matched only the
+ // epoch the re-claim drew, the row would outlive the socket and no
+ // caller could tell.
+ tun.Detach("w1", epoch)
+ Expect(tun.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection))
+ })
+
+ It("does not re-claim a tunnel whose session has already died", func() {
+ // Re-claiming is an upsert, so it takes the row from whoever holds it
+ // now. A worker whose socket here is dead has already reconnected
+ // somewhere, and claiming it back would point every dialer at a replica
+ // that cannot carry a byte to it.
+ live, _ := workerTunnel()
+ liveEpoch, err := tun.Attach(ctx, "live", live)
+ Expect(err).ToNot(HaveOccurred())
+ dead, deadWorker := workerTunnel()
+ _, err = tun.Attach(ctx, "dead", dead)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(deadWorker.Close()).To(Succeed())
+ Eventually(dead.IsClosed, "10s").Should(BeTrue())
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ membership.SetTunnels(tun)
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ reapSelf()
+ owner := func() (string, error) {
+ owner, _, err := reg.OwnerRow(ctx, "live")
+ return owner, err
+ }
+ Eventually(owner, 3*cluster.InstanceHeartbeat, time.Second).Should(Equal("me"))
+
+ _, reclaimedLive, err := reg.OwnerRow(ctx, "live")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reclaimedLive).ToNot(Equal(liveEpoch))
+
+ _, _, err = reg.OwnerRow(ctx, "dead")
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "a tunnel whose session is closed was claimed back from whoever holds the worker now")
+ })
+
+ It("records the re-claim on the attachment installed now, not the one it listed", func() {
+ // Reclaim lists the nodes it holds, then claims them one at a time. A
+ // worker that re-dials in between leaves an entry the list never saw.
+ // The claim is drawn under that node's gate, so it is the NEWEST claim
+ // for the node and the row carries it: recording it on the entry that
+ // is installed is the only thing that lets that entry release the row.
+ // Refusing to record it because the entry is not the one listed would
+ // leave the row behind when the socket dies.
+ hook := newClaimHook(isClaimOf("w1", "w2"))
+ hooked := cluster.NewTunnelRegistry(
+ cluster.NewRegistry(db.Session(&gorm.Session{Logger: hook})), "me")
+
+ for _, nodeID := range []string{"w1", "w2"} {
+ frontend, _ := workerTunnel()
+ _, err := hooked.Attach(ctx, nodeID, frontend)
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // The re-dial lands on whichever node this pass has not reached yet,
+ // so the spec does not depend on which one Reclaim takes first.
+ type redial struct {
+ node string
+ epoch int64
+ }
+ redialled := make(chan redial, 1)
+ hook.setAction(func(sql string) {
+ node := "w2"
+ if claimedNode(sql, "w1", "w2") == "w2" {
+ node = "w1"
+ }
+ frontend, _ := workerTunnel()
+ epoch, err := hooked.Attach(ctx, node, frontend)
+ Expect(err).ToNot(HaveOccurred())
+ redialled <- redial{node: node, epoch: epoch}
+ })
+
+ count, err := hooked.Reclaim(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(2))
+
+ var latest redial
+ Expect(redialled).To(Receive(&latest))
+ _, stored, err := reg.OwnerRow(ctx, latest.node)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored).ToNot(Equal(latest.epoch), "the re-claim never reached the node that re-dialled")
+
+ // The attachment that re-dialled is the one holding the socket, so its
+ // Detach has to be the one that removes the row.
+ hooked.Detach(latest.node, latest.epoch)
+ _, _, err = reg.OwnerRow(ctx, latest.node)
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "the re-claim was recorded on nothing, so the attachment that holds the socket cannot release its row")
+ })
+
+ It("serialises a re-claim against an Attach for the same node", func() {
+ // The re-claim takes the same gate Attach does, and for the same
+ // reason. Unserialised, a worker re-dialling between the re-claim's
+ // commit and its record leaves the row carrying the re-dial's epoch
+ // while the entry carries the re-claim's, so the attachment holding the
+ // socket releases an epoch the row does not have. Nothing sweeps the
+ // row that is left, because this replica is alive: Owner keeps naming
+ // it as the owner of a tunnel it does not hold.
+ hook := newClaimHook(isClaimOf("w1"))
+ hooked := cluster.NewTunnelRegistry(
+ cluster.NewRegistry(db.Session(&gorm.Session{Logger: hook})), "me")
+
+ first, _ := workerTunnel()
+ attachEpoch, err := hooked.Attach(ctx, "w1", first)
+ Expect(err).ToNot(HaveOccurred())
+
+ redialSession, _ := workerTunnel()
+ redialStarted := make(chan struct{})
+ redialEpochs := make(chan int64, 1)
+ hook.setAction(func(string) {
+ // Launched from inside the re-claim's own claim, so the re-dial is
+ // provably reaching for this node while the re-claim is between its
+ // commit and its record.
+ go func() {
+ defer GinkgoRecover()
+ close(redialStarted)
+ epoch, err := hooked.Attach(ctx, "w1", redialSession)
+ Expect(err).ToNot(HaveOccurred())
+ redialEpochs <- epoch
+ }()
+ <-redialStarted
+ Consistently(redialEpochs, serializationProbe, 10*time.Millisecond).ShouldNot(Receive(),
+ "a worker re-dialled and recorded its claim while a re-claim for the same node was between its own claim and record")
+ })
+
+ count, err := hooked.Reclaim(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(1))
+
+ var redialEpoch int64
+ Eventually(redialEpochs, "10s").Should(Receive(&redialEpoch))
+ Expect(redialEpoch).ToNot(Equal(attachEpoch))
+
+ // The re-dial claimed after the re-claim, so the row carries its epoch
+ // and its attachment is the one that has to be able to release it. The
+ // superseded token must still be a no-op.
+ hooked.Detach("w1", attachEpoch)
+ Expect(hooked.Held()).To(ConsistOf("w1"))
+ hooked.Detach("w1", redialEpoch)
+ Expect(hooked.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "the attachment that re-dialled could not release its row, so the row outlived the socket")
+ })
+
+ It("releases a re-claim whose attachment detached while the claim was in flight", func() {
+ // Detach is not gated against a re-claim, so this interleave is real:
+ // the claim commits, then the socket dies and Detach releases the epoch
+ // it was given, which the claim has already replaced. Left alone, the
+ // row names this replica for a tunnel it no longer holds, nothing
+ // sweeps it because this replica is alive, and Owner sends every dialer
+ // here to be told ErrNotOwner.
+ hook := newClaimHook(isClaimOf("w1"))
+ hooked := cluster.NewTunnelRegistry(
+ cluster.NewRegistry(db.Session(&gorm.Session{Logger: hook})), "me")
+
+ frontend, _ := workerTunnel()
+ epoch, err := hooked.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ hook.setAction(func(string) { hooked.Detach("w1", epoch) })
+
+ count, err := hooked.Reclaim(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeZero(), "a node that detached mid-claim was counted as re-claimed")
+
+ Expect(hooked.Held()).To(BeEmpty())
+ _, _, err = reg.OwnerRow(ctx, "w1")
+ Expect(err).To(MatchError(cluster.ErrNoConnection),
+ "the re-claim left a row behind that no attachment holds and no sweep will remove")
+ })
+
+ It("keeps re-claiming out of the ordinary heartbeat, which has nothing to rebuild", func() {
+ // A claim per tick would draw a fresh epoch every five seconds for
+ // every worker on this replica, and every one of those writes is a
+ // chance to take a row a reconnect has just moved elsewhere.
+ frontend, _ := workerTunnel()
+ epoch, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ membership := cluster.NewMembership(reg, "me", "10.0.0.1:8080", "v1")
+ membership.SetTunnels(tun)
+ Expect(membership.Start(ctx)).To(Succeed())
+ DeferCleanup(membership.Stop)
+
+ stored := func() (int64, error) {
+ _, stored, err := reg.OwnerRow(ctx, "w1")
+ return stored, err
+ }
+ Consistently(stored, 2*cluster.InstanceHeartbeat, time.Second).Should(Equal(epoch))
+ })
+
+ It("frees the node's gate when a re-claim panics", func() {
+ // The same property Attach's gate specs pin, on the other function that
+ // takes the gate. Reclaim runs from the heartbeat loop, so a wedged gate
+ // here is worse than one wedged by a dial: nothing retries it, and the
+ // worker can never re-attach to this replica because its Attach blocks
+ // in enterClaim until its own context expires.
+ //
+ // The panic is thrown from inside the re-claim's own Claim statement,
+ // on that statement's goroutine, using the same gorm Trace hook the
+ // interleaving specs above use. That is the window a real panic under
+ // Claim would land in: the row is written and the gate is held.
+ hook := newClaimHook(isClaimOf("w1"))
+ hooked := cluster.NewTunnelRegistry(
+ cluster.NewRegistry(db.Session(&gorm.Session{Logger: hook})), "me")
+
+ frontend, _ := workerTunnel()
+ _, err := hooked.Attach(ctx, "w1", frontend)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Installed after the attach so the hook fires on the RE-claim, not on
+ // the claim that set the tunnel up.
+ hook.setAction(func(string) { panic("claim exploded") })
+
+ panicked := func() (p bool) {
+ defer func() { p = recover() != nil }()
+ _, _ = hooked.Reclaim(ctx)
+ return
+ }()
+ Expect(panicked).To(BeTrue(),
+ "the hook did not fire inside the re-claim, so this spec is no longer testing what it claims")
+
+ // A wedged gate is indistinguishable from a slow one except by waiting.
+ // The hook has already fired once and will not fire again, so this
+ // Attach either completes or never reaches Claim at all.
+ bounded, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ next, _ := workerTunnel()
+ _, err = hooked.Attach(bounded, "w1", next)
+ Expect(err).ToNot(HaveOccurred(),
+ "the panicking re-claim left this node's gate closed, so the worker can never attach to this replica again")
+ })
+})
+
+var _ = Describe("The worker tunnel registry's claim gate", func() {
+ // These specs need no database. They pin what happens when the database
+ // call under the gate does not return normally, which is the one exit a
+ // release-on-every-return-path cannot cover.
+ //
+ // The panic is produced by the production code itself: a registry with no
+ // *Registry behind it dereferences nothing at the gate and then panics
+ // inside Claim, which is exactly where a real one does its work.
+ var tun *cluster.TunnelRegistry
+
+ BeforeEach(func() {
+ tun = cluster.NewTunnelRegistry(nil, "me")
+ })
+
+ // attachPanics runs one Attach that is expected to panic, swallowing the
+ // panic so the spec can go on to ask what state it left behind.
+ attachPanics := func(ctx context.Context, nodeID string) {
+ defer GinkgoRecover()
+ defer func() { _ = recover() }()
+ frontend, _ := workerTunnel()
+ _, _ = tun.Attach(ctx, nodeID, frontend)
+ Fail("Attach was expected to panic inside Claim, so this spec is no longer testing what it claims")
+ }
+
+ It("frees the node's gate when the claim panics", func() {
+ attachPanics(context.Background(), "w1")
+
+ // A wedged gate is indistinguishable from a slow one except by waiting,
+ // so the second attempt is given a deadline. Reaching Claim means
+ // panicking again; returning a context error means it never got past
+ // enterClaim and this worker could never reconnect to this replica.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ reached := make(chan any, 1)
+ go func() {
+ defer GinkgoRecover()
+ defer func() { reached <- recover() }()
+ frontend, _ := workerTunnel()
+ _, err := tun.Attach(ctx, "w1", frontend)
+ Expect(err).To(MatchError(context.DeadlineExceeded),
+ "the gate for this node was never released, so every later dial from it blocks until its own context expires")
+ }()
+ Eventually(reached, "5s").Should(Receive(Not(BeNil())),
+ "the second Attach did not reach Claim, so the panicking one left the gate closed")
+ })
+
+ It("leaves another node's gate alone", func() {
+ // The gate is per node so that one wedged worker cannot stop the rest;
+ // this holds that property against the panic path too.
+ attachPanics(context.Background(), "w1")
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ reached := make(chan any, 1)
+ go func() {
+ defer GinkgoRecover()
+ defer func() { reached <- recover() }()
+ frontend, _ := workerTunnel()
+ _, _ = tun.Attach(ctx, "w2", frontend)
+ }()
+ Eventually(reached, "5s").Should(Receive(Not(BeNil())))
+ })
+})
+
+var _ = Describe("Membership.SetReconnectGrace", func() {
+ It("is safe on a nil receiver, like SetTunnels and Stop", func() {
+ // Same reason SetTunnels is: core/application/distributed.go
+ // deliberately produces a nil *Membership when no peer-reachable
+ // address can be derived, so a setter that panicked on one would be a
+ // trap for the next caller rather than an impossibility.
+ var m *cluster.Membership
+ Expect(func() { m.SetReconnectGrace(time.Minute) }).ToNot(Panic())
+ })
+})
+
+var _ = Describe("Membership.SetTunnels", func() {
+ It("is safe on a nil receiver, like Stop", func() {
+ // A nil *Membership is a value this codebase deliberately produces when
+ // no peer-reachable address can be derived, so the asymmetry with Stop
+ // would be a trap for the next caller.
+ var m *cluster.Membership
+ Expect(func() { m.SetTunnels(cluster.NewTunnelRegistry(nil, "me")) }).ToNot(Panic())
+ })
+})
diff --git a/core/services/cluster/tunnelproto.go b/core/services/cluster/tunnelproto.go
new file mode 100644
index 000000000000..2e404b247c2a
--- /dev/null
+++ b/core/services/cluster/tunnelproto.go
@@ -0,0 +1,343 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+)
+
+// The framing every stream on a worker tunnel opens with.
+//
+// A yamux stream on its own carries no destination: the frontend opens one and
+// the worker has to be told what it is for. So the first thing on every stream
+// is a request frame naming a TAG (which local service) and a TARGET (which
+// instance of it), and the worker answers with a reply frame before either side
+// speaks the tunnelled protocol.
+//
+// The reply is not optional and is not sent only on failure, which is the part
+// that is easy to get wrong. The protocols carried here are client-speaks-first
+// (gRPC sends an HTTP/2 preface, HTTP sends a request line), so a reply sent
+// only when the worker refuses would arrive interleaved with a response body on
+// the streams that succeeded, and the frontend would have no safe moment to
+// look for it. Always sending one costs a round trip per stream, which is paid
+// once per pooled connection rather than once per request.
+//
+// Both frames are length-prefixed rather than newline-delimited so a reader
+// consumes exactly the header and not one byte of what follows: the stream is
+// handed to gRPC or net/http afterwards, and a buffered reader that over-read
+// would eat the beginning of their conversation.
+
+const (
+ // StreamTagGRPC routes a stream to a backend process on the worker. Its
+ // target is the address that backend listens on, which the worker resolves
+ // itself; see the worker's tunnel services for what it will accept.
+ StreamTagGRPC = "grpc"
+
+ // StreamTagHTTP routes a stream to the worker's own HTTP server, the one
+ // that serves file staging and backend logs. Its target is ignored: there
+ // is exactly one such server per worker and only the worker knows where it
+ // bound.
+ StreamTagHTTP = "http"
+)
+
+// maxTunnelFrame bounds a header frame. It is a defence against a peer that
+// declares a huge length and never sends it, not a size the protocol needs:
+// the longest real frame is a tag plus a host:port, well under a hundred
+// bytes. A reader that refuses early cannot be made to allocate on demand.
+const maxTunnelFrame = 1024
+
+// The reply codes. They travel on the wire, so they are strings rather than
+// integers: a frontend reading a code from a worker it does not recognise can
+// at least log something an operator can search for.
+const (
+ replyAccepted = "ok"
+ replyCodeUnknownTag = "unknown-tag"
+ replyCodeUnavailable = "unavailable"
+ replyCodeBadRequest = "bad-request"
+ replyCodeNotServed = "not-served"
+ replyPrefixRefused = "err "
+ streamRequestSeparator = " "
+)
+
+// The four refusals a worker can send, kept apart on purpose.
+//
+// This is the phase's standing rule in its wire form. An unknown tag is a fact
+// about what this worker SERVES and will not change until the worker is
+// upgraded; an unavailable target is the worker's own dial to the named
+// process failing, which is what a backend that died looks like from inside
+// the worker; a bad request is this frontend's own bug. A caller gives up on
+// the first, acts on the second, and reports the third. Collapsing them into
+// one error would make a frontend retry a stream that can never work, or
+// abandon a backend that was merely restarting.
+//
+// The fourth is the one that says NOTHING, and it exists because the first
+// three all say something a consumer now acts on. See ErrStreamNotServed.
+//
+// None of them wraps a node-absence error, and none must ever be built over
+// one: a refusal is proof the worker is CONNECTED and answered.
+var (
+ ErrStreamTagUnknown = errors.New("cluster: the worker does not serve that stream tag")
+ ErrStreamTargetUnavailable = errors.New("cluster: the worker could not reach the local service for that stream")
+ ErrStreamRequestInvalid = errors.New("cluster: the worker rejected the stream request as malformed")
+
+ // ErrStreamNotServed reports that the worker could not serve the stream for
+ // a reason of ITS OWN, which is not a statement about the backend the
+ // stream named.
+ //
+ // It is the refusal a worker sends when it has learned nothing. The other
+ // three are evidence a frontend ACTS on: IsWorkerAnswer exempts them from
+ // the no-route umbrella, and nodes.unroutable then lets a reap guard delete
+ // the row. This one is deliberately OUTSIDE that predicate, so it reaches a
+ // consumer as ErrNoRoute and nothing is reaped.
+ //
+ // It exists because of a defect this phase created and then found: the
+ // worker used to answer a request frame that merely arrived LATE with
+ // ErrStreamRequestInvalid, which was harmless while the frontend treated
+ // every refusal as "no route", and became a reap the moment a frontend
+ // started acting on refusals. A delivery timeout clears as soon as the link
+ // drains; a malformed frame does not. Merging them was safe only while
+ // nothing downstream could tell them apart, and something downstream now
+ // can. Anything the worker cannot classify as one of the other three
+ // belongs here, because an unclassified failure is by definition not a
+ // verdict about a backend.
+ //
+ // A frontend too old to know this code reads it as an unrecognised reply,
+ // which ReadStreamReply already returns as a plain error and which
+ // IsWorkerAnswer already declines to count as an answer. So the safe
+ // behaviour is what a mixed-version deployment gets for free.
+ ErrStreamNotServed = errors.New("cluster: the worker could not serve that stream, for a reason that is not about the backend")
+)
+
+// streamRefusals is the whole refusal vocabulary, in ONE table.
+//
+// The writer, the reader, IsWorkerAnswer and IsStreamRefusal all read it, so a
+// fifth refusal cannot be taught to some of them and forgotten in the others.
+// That is not hypothetical tidiness: the fourth code was added to the writer,
+// the reader and the consumer predicate, and a fifth place that enumerated the
+// sentinels by hand (the worker's classifyServiceFailure) silently PROMOTED it
+// to a reaping verdict. One table is what makes the next such site impossible
+// to write.
+//
+// ORDER MATTERS for the writer: matching is by errors.Is and the first hit
+// wins, so a reason wrapping two sentinels resolves the same way every time.
+//
+// evidence is the half a consumer acts on, and it is a property OF THE CODE
+// rather than of the consumer asking. Three of the four are statements about a
+// backend that a frontend may reap on; ErrStreamNotServed is the worker saying
+// it learned nothing, and folding it in turns every transient worker-side
+// failure into an eviction. See IsWorkerAnswer.
+var streamRefusals = []struct {
+ sentinel error
+ code string
+ evidence bool
+}{
+ {ErrStreamTagUnknown, replyCodeUnknownTag, true},
+ {ErrStreamTargetUnavailable, replyCodeUnavailable, true},
+ {ErrStreamRequestInvalid, replyCodeBadRequest, true},
+ {ErrStreamNotServed, replyCodeNotServed, false},
+}
+
+// IsStreamRefusal reports whether err ALREADY carries one of this vocabulary's
+// classifications.
+//
+// It answers "has something already decided what this failure is", which is a
+// different question from IsWorkerAnswer's "may a consumer act on it". A worker
+// that re-classifies an error which already carries a sentinel overwrites a
+// decision made closer to the failure, and when the overwrite lands on one of
+// the three evidence codes it manufactures a verdict out of something that was
+// explicitly not one.
+func IsStreamRefusal(err error) bool {
+ for _, r := range streamRefusals {
+ if errors.Is(err, r.sentinel) {
+ return true
+ }
+ }
+ return false
+}
+
+// WriteStreamRequest sends the opening frame naming what the stream is for.
+//
+// An empty tag is refused here rather than on the wire, because the worker
+// would answer it with ErrStreamRequestInvalid and the caller would learn a
+// round trip later what it could have been told at once.
+func WriteStreamRequest(w io.Writer, tag, target string) error {
+ if tag == "" {
+ return fmt.Errorf("writing a tunnel stream request: empty tag")
+ }
+ if strings.Contains(tag, streamRequestSeparator) {
+ // The separator is a single space and the split is on the FIRST one, so
+ // a tag containing a space would silently move part of itself into the
+ // target.
+ return fmt.Errorf("writing a tunnel stream request: tag %q contains a space", tag)
+ }
+ return writeFrame(w, tag+streamRequestSeparator+target)
+}
+
+// ReadStreamRequest reads the opening frame. The target is empty when the tag
+// carries no argument.
+//
+// A malformed frame is returned as an ordinary error, NOT as
+// ErrStreamRequestInvalid: that sentinel is what a worker SENDS to describe a
+// refusal, and a reader that produced it here would leave a caller unable to
+// tell "the peer refused my request" from "I could not read the peer's".
+func ReadStreamRequest(r io.Reader) (tag, target string, err error) {
+ payload, err := readFrame(r)
+ if err != nil {
+ return "", "", fmt.Errorf("reading a tunnel stream request: %w", err)
+ }
+ tag, target, _ = strings.Cut(payload, streamRequestSeparator)
+ if tag == "" {
+ return "", "", fmt.Errorf("reading a tunnel stream request: empty tag")
+ }
+ return tag, target, nil
+}
+
+// WriteStreamAccepted tells the frontend the stream is now carrying the
+// tunnelled protocol. Everything after this frame belongs to that protocol.
+func WriteStreamAccepted(w io.Writer) error {
+ return writeFrame(w, replyAccepted)
+}
+
+// WriteStreamRefusal reports why a stream will not be served. The caller closes
+// the stream afterwards; this only says why.
+//
+// An unrecognised reason is sent as NOT-SERVED with its text attached rather
+// than being dropped, because a refusal a frontend cannot read is
+// indistinguishable from a worker that hung up, and those are different
+// problems.
+//
+// The default is not-served and not bad-request, and the difference is the
+// whole point of the fourth code. The other three are evidence a frontend acts
+// on, up to and including deleting a model's row; an error that reached here
+// without carrying one of them is by construction an error nobody classified,
+// and an unclassified failure must never become a verdict by default. This
+// default used to be bad-request, which was harmless while no consumer
+// distinguished the codes and became a reap-by-omission when one did.
+func WriteStreamRefusal(w io.Writer, reason error) error {
+ code := replyCodeNotServed
+ for _, r := range streamRefusals {
+ if errors.Is(reason, r.sentinel) {
+ code = r.code
+ break
+ }
+ }
+
+ text := ""
+ if reason != nil {
+ text = strings.Map(func(r rune) rune {
+ // The frame is length-prefixed so a newline would not corrupt it,
+ // but this text reaches a log line on the far side and a cause
+ // spanning lines is what makes one unsearchable.
+ if r == '\n' || r == '\r' {
+ return ' '
+ }
+ return r
+ }, reason.Error())
+ }
+ frame := replyPrefixRefused + code + streamRequestSeparator + text
+ return writeFrame(w, truncateRunes(frame, maxTunnelFrame))
+}
+
+// ReadStreamReply reads the worker's answer. nil means the stream is now
+// carrying the tunnelled protocol.
+//
+// A failure to READ the reply is returned as itself, never as one of the
+// refusal sentinels. The distinction is the point of this function: a refusal
+// means the worker is connected and said no, while a read failure means the
+// tunnel broke, and a caller that treated the second as the first would report
+// a dead link as a policy decision.
+func ReadStreamReply(r io.Reader) error {
+ payload, err := readFrame(r)
+ if err != nil {
+ return fmt.Errorf("reading a tunnel stream reply: %w", err)
+ }
+ if payload == replyAccepted {
+ return nil
+ }
+ rest, ok := strings.CutPrefix(payload, replyPrefixRefused)
+ if !ok {
+ return fmt.Errorf("reading a tunnel stream reply: unrecognised reply %q", payload)
+ }
+ code, text, _ := strings.Cut(rest, streamRequestSeparator)
+ for _, r := range streamRefusals {
+ if code == r.code {
+ return fmt.Errorf("%w: %s", r.sentinel, text)
+ }
+ }
+ // A code from a newer worker. Reported as an error carrying the code rather
+ // than mapped onto the nearest known one, so a frontend does not retry
+ // forever against a refusal that means something else entirely, and so
+ // IsWorkerAnswer reports false for it and nothing reaps.
+ return fmt.Errorf("tunnel stream refused with unrecognised code %q: %s", code, text)
+}
+
+// truncateRunes cuts s to at most limit BYTES, on a rune boundary.
+//
+// A plain slice would cut mid-rune and put a lone continuation byte on the
+// wire. Nothing breaks: the frame is length-prefixed so the framing survives,
+// and the reader's string() tolerates invalid UTF-8. What it costs is the
+// far side's log line ending in a replacement character, and a refusal reason
+// exists to be read by a person, so it should not arrive damaged.
+//
+// The code that reaches this is always short; only a cause from a local service
+// can be long enough to matter.
+func truncateRunes(s string, limit int) string {
+ if len(s) <= limit {
+ return s
+ }
+ cut := limit
+ // utf8.RuneStart finds the first byte of a rune. Walking back from the
+ // limit lands on the start of the rune that would have been split, and at
+ // most 3 steps are needed since a UTF-8 rune is at most 4 bytes.
+ for cut > 0 && !utf8.RuneStart(s[cut]) {
+ cut--
+ }
+ return s[:cut]
+}
+
+// writeFrame writes one length-prefixed frame in a single Write.
+//
+// One Write, not two: the underlying stream is a yamux stream whose writes
+// become discrete data frames, and splitting the length from the payload would
+// put the reader one frame away from a header for no reason. It also keeps the
+// adapter in wsconn.go to one WebSocket message per frame.
+func writeFrame(w io.Writer, payload string) error {
+ if len(payload) > maxTunnelFrame {
+ return fmt.Errorf("tunnel frame is %d bytes, over the %d-byte limit", len(payload), maxTunnelFrame)
+ }
+ buf := make([]byte, 2+len(payload))
+ binary.BigEndian.PutUint16(buf[:2], uint16(len(payload)))
+ copy(buf[2:], payload)
+ _, err := w.Write(buf)
+ return err
+}
+
+// readFrame reads one length-prefixed frame.
+//
+// io.ReadFull rather than Read: a yamux stream returns whatever has arrived,
+// and a header split across two data frames is ordinary rather than
+// exceptional. It also converts a truncated frame into io.ErrUnexpectedEOF,
+// which is what a peer that hung up mid-header should look like.
+func readFrame(r io.Reader) (string, error) {
+ var size [2]byte
+ if _, err := io.ReadFull(r, size[:]); err != nil {
+ return "", err
+ }
+ n := binary.BigEndian.Uint16(size[:])
+ if int(n) > maxTunnelFrame {
+ return "", fmt.Errorf("tunnel frame declares %d bytes, over the %d-byte limit", n, maxTunnelFrame)
+ }
+ if n == 0 {
+ return "", nil
+ }
+ payload := make([]byte, n)
+ if _, err := io.ReadFull(r, payload); err != nil {
+ return "", err
+ }
+ return string(payload), nil
+}
diff --git a/core/services/cluster/tunnelproto_test.go b/core/services/cluster/tunnelproto_test.go
new file mode 100644
index 000000000000..2c7e8d5d061f
--- /dev/null
+++ b/core/services/cluster/tunnelproto_test.go
@@ -0,0 +1,255 @@
+package cluster_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+)
+
+var _ = Describe("Worker tunnel stream framing", func() {
+ Describe("the request frame", func() {
+ DescribeTable("round-trips a tag and a target",
+ func(tag, target string) {
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRequest(&buf, tag, target)).To(Succeed())
+ gotTag, gotTarget, err := cluster.ReadStreamRequest(&buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(gotTag).To(Equal(tag))
+ Expect(gotTarget).To(Equal(target))
+ },
+ Entry("a tag and an address", cluster.StreamTagGRPC, "127.0.0.1:50051"),
+ Entry("a tag with no target", cluster.StreamTagHTTP, ""),
+ // The split is on the FIRST separator, so a target containing one
+ // must survive intact.
+ Entry("a target containing a space", cluster.StreamTagGRPC, "a b c"),
+ )
+
+ It("consumes exactly the frame and not one byte of what follows", func() {
+ // Load-bearing: the stream is handed to gRPC or net/http right
+ // after this, and a reader that over-read would eat the start of
+ // their conversation.
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRequest(&buf, cluster.StreamTagGRPC, "127.0.0.1:1")).To(Succeed())
+ buf.WriteString("PRI * HTTP/2.0")
+
+ _, _, err := cluster.ReadStreamRequest(&buf)
+ Expect(err).ToNot(HaveOccurred())
+ rest, err := io.ReadAll(&buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(rest)).To(Equal("PRI * HTTP/2.0"))
+ })
+
+ DescribeTable("refuses a tag it could not encode unambiguously",
+ func(tag string) {
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRequest(&buf, tag, "x")).ToNot(Succeed())
+ Expect(buf.Len()).To(BeZero(), "a refused request must not put a partial frame on the wire")
+ },
+ Entry("empty", ""),
+ // A tag with a space would silently move part of itself into the
+ // target, so it is refused at the writer rather than a round trip
+ // later.
+ Entry("containing a space", "grpc stream"),
+ )
+
+ It("refuses an over-long declared length after reading only the header", func() {
+ // The name used to say "without allocating it" and the spec
+ // measured nothing of the sort. What is actually checkable, and is
+ // the mechanism the defence rests on, is that the reader STOPS: it
+ // consumes the two length bytes and not one byte of the body, so a
+ // peer cannot make it allocate or read on demand.
+ //
+ // The body is present in the input on purpose. With an input that
+ // ends after the header, a reader that went on to read the body
+ // would still consume nothing more, and this assertion would pass
+ // with the limit check deleted.
+ var hdr [2]byte
+ binary.BigEndian.PutUint16(hdr[:], 65535)
+ src := &countingReader{r: bytes.NewReader(append(hdr[:], make([]byte, 4096)...))}
+
+ _, _, err := cluster.ReadStreamRequest(src)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("over the"))
+ Expect(src.n).To(Equal(2), "the reader consumed part of a frame it had already refused")
+ })
+
+ It("reports a truncated frame as a truncated read, not as a refusal", func() {
+ // ReadStreamRequest must never produce ErrStreamRequestInvalid:
+ // that sentinel is what a worker SENDS, and a reader producing it
+ // would leave a caller unable to tell "the peer refused me" from
+ // "I could not read the peer".
+ var hdr [2]byte
+ binary.BigEndian.PutUint16(hdr[:], 10)
+ _, _, err := cluster.ReadStreamRequest(bytes.NewReader(append(hdr[:], 'a')))
+ Expect(err).To(MatchError(io.ErrUnexpectedEOF))
+ Expect(err).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ })
+ })
+
+ Describe("the reply frame", func() {
+ It("reads an acceptance as nil", func() {
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamAccepted(&buf)).To(Succeed())
+ Expect(cluster.ReadStreamReply(&buf)).To(Succeed())
+ })
+
+ DescribeTable("keeps the four refusals apart",
+ func(sent error, others []error) {
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, sent)).To(Succeed())
+ got := cluster.ReadStreamReply(&buf)
+ Expect(got).To(MatchError(sent))
+ // The whole point. A caller gives up on an unknown tag, acts on
+ // an unavailable target, reports a bad request as its own bug,
+ // and learns NOTHING from a not-served; collapsing any pair
+ // makes one of those wrong, and three of the four pairs end in
+ // a reaped replica.
+ for _, other := range others {
+ Expect(got).ToNot(MatchError(other))
+ }
+ },
+ Entry("unknown tag", cluster.ErrStreamTagUnknown,
+ []error{cluster.ErrStreamTargetUnavailable, cluster.ErrStreamRequestInvalid, cluster.ErrStreamNotServed}),
+ Entry("unavailable target", cluster.ErrStreamTargetUnavailable,
+ []error{cluster.ErrStreamTagUnknown, cluster.ErrStreamRequestInvalid, cluster.ErrStreamNotServed}),
+ Entry("invalid request", cluster.ErrStreamRequestInvalid,
+ []error{cluster.ErrStreamTagUnknown, cluster.ErrStreamTargetUnavailable, cluster.ErrStreamNotServed}),
+ Entry("nothing learned", cluster.ErrStreamNotServed,
+ []error{cluster.ErrStreamTagUnknown, cluster.ErrStreamTargetUnavailable, cluster.ErrStreamRequestInvalid}),
+ )
+
+ It("sends a reason it cannot classify as not-served, never as a verdict", func() {
+ // The default, and it is a safety default rather than a formality.
+ // Three of the four codes are evidence a frontend now ACTS on, up
+ // to deleting a model's row; an error that reached WriteStreamRefusal
+ // without carrying a sentinel is by construction one nobody
+ // classified. This default used to be bad-request, which was
+ // harmless while no consumer distinguished the codes and became a
+ // reap-by-omission the moment one did.
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, errors.New("something nobody thought about"))).To(Succeed())
+ got := cluster.ReadStreamReply(&buf)
+ Expect(got).To(MatchError(cluster.ErrStreamNotServed))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeFalse(),
+ "an unclassified failure must never become the worker's verdict about a backend")
+ Expect(got.Error()).To(ContainSubstring("something nobody thought about"))
+ })
+
+ It("keeps not-served OUT of the answers a frontend acts on", func() {
+ // The predicate is the seam between what the worker says and what
+ // the frontend does with it. The other three are exempted from the
+ // no-route umbrella so a crashed backend can be reaped; this one
+ // must not be, or every transient worker-side failure reaps.
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, cluster.ErrStreamNotServed)).To(Succeed())
+ Expect(cluster.IsWorkerAnswer(cluster.ReadStreamReply(&buf))).To(BeFalse())
+
+ for _, verdict := range []error{cluster.ErrStreamTagUnknown, cluster.ErrStreamTargetUnavailable, cluster.ErrStreamRequestInvalid} {
+ buf.Reset()
+ Expect(cluster.WriteStreamRefusal(&buf, verdict)).To(Succeed())
+ Expect(cluster.IsWorkerAnswer(cluster.ReadStreamReply(&buf))).To(BeTrue(),
+ "a verdict that stopped being an answer makes a dead backend unreapable")
+ }
+ })
+
+ It("carries the reason text to the far side", func() {
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, wrapReason(cluster.ErrStreamTagUnknown, "no-such-tag"))).To(Succeed())
+ Expect(cluster.ReadStreamReply(&buf).Error()).To(ContainSubstring("no-such-tag"))
+ })
+
+ It("reports an unrecognised code as itself, not as the nearest known one", func() {
+ // A code from a newer worker. Mapping it onto a known sentinel
+ // would make a frontend retry forever against a refusal that means
+ // something else entirely.
+ var buf bytes.Buffer
+ writeRawFrame(&buf, "err teapot short and stout")
+ got := cluster.ReadStreamReply(&buf)
+ Expect(got).To(HaveOccurred())
+ Expect(got.Error()).To(ContainSubstring("teapot"))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTagUnknown))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamNotServed))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeFalse())
+ })
+
+ It("reports a failure to READ the reply as itself, never as a refusal", func() {
+ // A refusal proves the worker is connected and said no. A read
+ // failure means the tunnel broke. A caller that treated the second
+ // as the first would report a dead link as a policy decision.
+ got := cluster.ReadStreamReply(bytes.NewReader(nil))
+ Expect(got).To(MatchError(io.EOF))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTagUnknown))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamNotServed))
+ })
+
+ It("truncates an over-long reason on a rune boundary, keeping it decodable", func() {
+ // Two-byte runes so a byte-boundary cut lands mid-rune for half of
+ // all lengths; the padding tunes the frame to land exactly there.
+ reason := wrapReason(cluster.ErrStreamTargetUnavailable, strings.Repeat("é", 2000))
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, reason)).To(Succeed())
+
+ got := cluster.ReadStreamReply(&buf)
+ Expect(got).To(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(utf8.ValidString(got.Error())).To(BeTrue(),
+ "the truncated reason reached the far side with a split rune in it")
+ })
+
+ It("still reports the code when the reason is truncated away", func() {
+ // The code must survive truncation: a refusal a frontend cannot
+ // classify is indistinguishable from a worker that hung up.
+ reason := wrapReason(cluster.ErrStreamTagUnknown, strings.Repeat("x", 4000))
+ var buf bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&buf, reason)).To(Succeed())
+ Expect(cluster.ReadStreamReply(&buf)).To(MatchError(cluster.ErrStreamTagUnknown))
+ })
+ })
+})
+
+// wrapReason builds the shape the worker sends: a sentinel with a cause.
+func wrapReason(sentinel error, text string) error {
+ return &reasonErr{sentinel: sentinel, text: text}
+}
+
+type reasonErr struct {
+ sentinel error
+ text string
+}
+
+func (e *reasonErr) Error() string { return e.sentinel.Error() + ": " + e.text }
+func (e *reasonErr) Unwrap() error { return e.sentinel }
+
+// countingReader records how many bytes were actually consumed, so a spec can
+// assert where a reader stopped rather than only what it returned.
+type countingReader struct {
+ r io.Reader
+ n int
+}
+
+func (c *countingReader) Read(p []byte) (int, error) {
+ n, err := c.r.Read(p)
+ c.n += n
+ return n, err
+}
+
+// writeRawFrame puts a payload on the wire without going through the encoder,
+// so a spec can present a frame the encoder would never produce.
+func writeRawFrame(buf *bytes.Buffer, payload string) {
+ var hdr [2]byte
+ binary.BigEndian.PutUint16(hdr[:], uint16(len(payload)))
+ buf.Write(hdr[:])
+ buf.WriteString(payload)
+}
diff --git a/core/services/cluster/tunnelproto_wire_test.go b/core/services/cluster/tunnelproto_wire_test.go
new file mode 100644
index 000000000000..0745444dad08
--- /dev/null
+++ b/core/services/cluster/tunnelproto_wire_test.go
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+// In-package, and that is the point: these specs assert the BYTES a refusal
+// puts on the wire, against literals written out here rather than against the
+// constants the code uses. A spec that round-trips through this process's own
+// writer and reader cannot see a rename, because a rename moves both sides at
+// once; the DescribeTable in tunnelproto_test.go is exactly that spec and it
+// stays green through any renaming of the four codes.
+//
+// A wire code is a cross-version contract. A worker and a frontend built from
+// different commits talk to each other over it, and the consequence of them
+// disagreeing is not a parse error: an unrecognised code is deliberately
+// treated as "not the worker's answer", so renaming `unavailable` would turn
+// every crashed backend on a tunnelled worker into a row nothing can ever reap,
+// silently and with the whole suite green. That is the exact defect this phase
+// spent two rounds removing.
+//
+// This branch set the precedent for pinning a vocabulary against literals in
+// core/services/messaging/subjects_wire_test.go, for the same reason. Nothing
+// has shipped yet, so no value here is load bearing across a release boundary
+// today; that is why the literals may still be changed, and why the change has
+// to be deliberate rather than incidental.
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// frameBytes returns the payload of the single frame w wrote, so a spec can
+// assert on the bytes rather than on what the reader makes of them.
+func frameBytes(write func(w *bytes.Buffer) error) string {
+ GinkgoHelper()
+ var buf bytes.Buffer
+ Expect(write(&buf)).To(Succeed())
+ raw := buf.Bytes()
+ Expect(len(raw)).To(BeNumerically(">=", 2))
+ Expect(binary.BigEndian.Uint16(raw[:2])).To(Equal(uint16(len(raw) - 2)))
+ return string(raw[2:])
+}
+
+var _ = Describe("the tunnel refusal vocabulary on the wire", func() {
+ DescribeTable("writes the exact code an older build reads",
+ func(sentinel error, wantCode string) {
+ payload := frameBytes(func(w *bytes.Buffer) error {
+ return WriteStreamRefusal(w, fmt.Errorf("%w: because", sentinel))
+ })
+ Expect(payload).To(Equal("err " + wantCode + " " + sentinel.Error() + ": because"))
+ },
+ Entry("unknown tag", ErrStreamTagUnknown, "unknown-tag"),
+ Entry("unavailable target", ErrStreamTargetUnavailable, "unavailable"),
+ Entry("invalid request", ErrStreamRequestInvalid, "bad-request"),
+ Entry("nothing learned", ErrStreamNotServed, "not-served"),
+ )
+
+ DescribeTable("reads the exact code an older build writes",
+ func(rawCode string, want error) {
+ var buf bytes.Buffer
+ Expect(writeFrame(&buf, "err "+rawCode+" some reason")).To(Succeed())
+ Expect(ReadStreamReply(&buf)).To(MatchError(want))
+ },
+ Entry("unknown tag", "unknown-tag", ErrStreamTagUnknown),
+ Entry("unavailable target", "unavailable", ErrStreamTargetUnavailable),
+ Entry("invalid request", "bad-request", ErrStreamRequestInvalid),
+ Entry("nothing learned", "not-served", ErrStreamNotServed),
+ )
+
+ It("accepts a stream with the literal an older build sends", func() {
+ // The success case has a literal too, and a rename of it would refuse
+ // every stream rather than mis-classify one, which is at least loud.
+ Expect(frameBytes(func(w *bytes.Buffer) error { return WriteStreamAccepted(w) })).To(Equal("ok"))
+ })
+
+ It("names the two stream tags with the literals the worker routes on", func() {
+ // The worker's routing table is keyed by these, so a rename here is a
+ // worker that serves nothing while reporting an unknown tag, which is a
+ // verdict a frontend acts on.
+ Expect(StreamTagGRPC).To(Equal("grpc"))
+ Expect(StreamTagHTTP).To(Equal("http"))
+ })
+
+ It("pins which codes a frontend may act on as evidence about a backend", func() {
+ // The half of the table that decides whether a row is deleted. It is
+ // asserted against the literals, not against IsWorkerAnswer's own
+ // output, so moving a code between the two columns reddens here as well
+ // as at the consumer.
+ evidence := map[string]bool{}
+ for _, r := range streamRefusals {
+ evidence[r.code] = r.evidence
+ }
+ Expect(evidence).To(Equal(map[string]bool{
+ "unknown-tag": true,
+ "unavailable": true,
+ "bad-request": true,
+ "not-served": false,
+ }), "a code that changed column changes whether a live model gets evicted")
+ })
+
+ It("has exactly one entry per sentinel, and no duplicate codes", func() {
+ // A duplicate code makes the reader's first match win and the writer's
+ // first match win, which need not be the same entry.
+ codes := map[string]int{}
+ for _, r := range streamRefusals {
+ Expect(r.sentinel).ToNot(BeNil())
+ codes[r.code]++
+ }
+ Expect(codes).To(HaveLen(len(streamRefusals)))
+ for code, n := range codes {
+ Expect(n).To(Equal(1), "code %q appears %d times", code, n)
+ }
+ })
+
+ It("classifies every sentinel as a refusal, and nothing else as one", func() {
+ // IsStreamRefusal is what stops the worker re-classifying a decision
+ // something closer to the failure already made. Derived from the table
+ // so a fifth code joins it automatically; asserted here so that
+ // derivation cannot quietly stop.
+ for _, r := range streamRefusals {
+ Expect(IsStreamRefusal(fmt.Errorf("wrapped: %w", r.sentinel))).To(BeTrue(), r.code)
+ }
+ Expect(IsStreamRefusal(errors.New("a plain failure"))).To(BeFalse())
+ Expect(IsStreamRefusal(nil)).To(BeFalse())
+ })
+})
diff --git a/core/services/cluster/wsconn.go b/core/services/cluster/wsconn.go
new file mode 100644
index 000000000000..aa8346de6453
--- /dev/null
+++ b/core/services/cluster/wsconn.go
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: MIT
+
+package cluster
+
+import (
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// WebsocketConn adapts a gorilla WebSocket into the net.Conn that a yamux
+// session drives.
+//
+// The two disagree about framing: WebSocket delivers whole messages, yamux
+// wants an undelimited byte stream. The adapter therefore keeps the reader of
+// the message it is part-way through between calls, so a Read whose buffer is
+// smaller than the message hands back a prefix now and the rest next time
+// instead of dropping the tail. That case is not hypothetical: yamux reads
+// through a 4 KiB bufio.Reader while a single stream write can put a much
+// larger data frame on the wire in one Write, so any message above the buffer
+// size is read in pieces.
+//
+// The returned conn is safe for one reader and one writer concurrently, plus a
+// third goroutine setting deadlines, which is what the relay needs: yamux's
+// sendLoop writes while a supervisor arms an idle deadline. It is not a
+// general-purpose net.Conn.
+func WebsocketConn(ws *websocket.Conn) net.Conn {
+ return &wsConn{ws: ws}
+}
+
+type wsConn struct {
+ ws *websocket.Conn
+
+ // readMu guards frame, which carries a partially consumed message across
+ // Read calls. gorilla allows a single concurrent reader, and this keeps
+ // the adapter to that contract even if a caller reads from two goroutines.
+ readMu sync.Mutex
+ frame io.Reader
+
+ // writeMu keeps to gorilla's one-concurrent-writer contract.
+ writeMu sync.Mutex
+}
+
+func (c *wsConn) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+
+ c.readMu.Lock()
+ defer c.readMu.Unlock()
+
+ for {
+ if c.frame == nil {
+ messageType, r, err := c.ws.NextReader()
+ if err != nil {
+ return 0, translateReadErr(err)
+ }
+ // Binary is the only type this link speaks. Skipping an unexpected
+ // text message would silently desynchronise the yamux framing, so
+ // it is reported instead.
+ if messageType != websocket.BinaryMessage {
+ return 0, fmt.Errorf("cluster: peer link received websocket message type %d, want binary", messageType)
+ }
+ c.frame = r
+ }
+
+ n, err := c.frame.Read(p)
+ if err == io.EOF {
+ // End of one message, not end of the stream: drop the reader so
+ // the next call pulls the next message. Passing io.EOF up would
+ // end the yamux session at an arbitrary message boundary.
+ c.frame = nil
+ err = nil
+ }
+ if n > 0 || err != nil {
+ return n, err
+ }
+ // A zero-length message yields nothing to return, and (0, nil) reads
+ // look like a stalled stream to some callers, so wait for the next one.
+ }
+}
+
+func (c *wsConn) Write(p []byte) (int, error) {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+
+ if err := c.ws.WriteMessage(websocket.BinaryMessage, p); err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
+
+// Close drops the underlying network connection without negotiating a
+// WebSocket close handshake. yamux has already sent its own go-away by this
+// point, and a close frame would need the write lock that a blocked sendLoop
+// may still hold.
+func (c *wsConn) Close() error {
+ return c.ws.Close()
+}
+
+func (c *wsConn) LocalAddr() net.Addr { return c.ws.LocalAddr() }
+func (c *wsConn) RemoteAddr() net.Addr { return c.ws.RemoteAddr() }
+
+func (c *wsConn) SetDeadline(t time.Time) error {
+ if err := c.SetReadDeadline(t); err != nil {
+ return err
+ }
+ return c.SetWriteDeadline(t)
+}
+
+// SetReadDeadline needs no lock, and must not take readMu: gorilla passes the
+// read deadline straight to the underlying net.Conn, whose deadline setters are
+// safe to call from another goroutine, and taking readMu would block behind the
+// parked Read this call exists to unblock.
+func (c *wsConn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
+
+// SetWriteDeadline takes writeMu because gorilla stores the write deadline in a
+// plain struct field (conn.go:796) and applies it when it next flushes, so
+// setting it while a write is in flight is a data race, not merely a late bound.
+func (c *wsConn) SetWriteDeadline(t time.Time) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+
+ return c.ws.SetWriteDeadline(t)
+}
+
+// translateReadErr maps a peer hanging up cleanly onto io.EOF, which is how a
+// yamux session recognises a normal ending. Any other close code, and any
+// transport error, is passed through so the session reports a real failure.
+func translateReadErr(err error) error {
+ if websocket.IsCloseError(err,
+ websocket.CloseNormalClosure,
+ websocket.CloseGoingAway,
+ websocket.CloseNoStatusReceived,
+ ) {
+ return io.EOF
+ }
+ return err
+}
diff --git a/core/services/cluster/wsconn_test.go b/core/services/cluster/wsconn_test.go
new file mode 100644
index 000000000000..d7213fd87779
--- /dev/null
+++ b/core/services/cluster/wsconn_test.go
@@ -0,0 +1,290 @@
+package cluster_test
+
+import (
+ "bytes"
+ "crypto/rand"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// wsPair returns the two ends of one live WebSocket connection.
+func wsPair() (clientSide, serverSide *websocket.Conn) {
+ GinkgoHelper()
+
+ upgrader := websocket.Upgrader{}
+ accepted := make(chan *websocket.Conn, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ accepted <- ws
+ }))
+ DeferCleanup(srv.Close)
+
+ c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = c.Close() })
+
+ var s *websocket.Conn
+ Eventually(accepted, "5s").Should(Receive(&s))
+ DeferCleanup(func() { _ = s.Close() })
+
+ return c, s
+}
+
+var _ = Describe("WebsocketConn framing", func() {
+ // The specs below exist because the brief's end-to-end yamux spec cannot
+ // catch a lost message tail: yamux reads through a 4 KiB bufio.Reader, so
+ // every small message arrives whole no matter how the adapter behaves.
+ // These drive the adapter directly with buffers smaller than the message.
+
+ It("returns the rest of a message on the following Read", func() {
+ clientWS, serverWS := wsPair()
+ writer := cluster.WebsocketConn(clientWS)
+ reader := cluster.WebsocketConn(serverWS)
+
+ // A lost tail would otherwise park the reassembly below forever; with a
+ // deadline it fails as a timeout on the read that has nothing left.
+ Expect(reader.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+
+ payload := []byte("0123456789abcdefghijklmnopqrstuvwxyz")
+ n, err := writer.Write(payload)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(n).To(Equal(len(payload)))
+
+ // Deliberately smaller than the message: a naive adapter that starts a
+ // fresh NextReader on every call drops everything past the first 7
+ // bytes, and this reassembly fails.
+ got := make([]byte, 0, len(payload))
+ buf := make([]byte, 7)
+ for len(got) < len(payload) {
+ read, err := reader.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(read).To(BeNumerically(">", 0))
+ Expect(read).To(BeNumerically("<=", len(buf)))
+ got = append(got, buf[:read]...)
+ }
+ Expect(got).To(Equal(payload))
+ })
+
+ It("streams a message larger than the yamux read buffer without loss or reordering", func() {
+ clientWS, serverWS := wsPair()
+ writer := cluster.WebsocketConn(clientWS)
+ reader := cluster.WebsocketConn(serverWS)
+
+ Expect(reader.SetReadDeadline(time.Now().Add(20 * time.Second))).To(Succeed())
+
+ payload := make([]byte, 256*1024)
+ _, err := rand.Read(payload)
+ Expect(err).ToNot(HaveOccurred())
+
+ go func() {
+ defer GinkgoRecover()
+ _, _ = writer.Write(payload)
+ }()
+
+ // 4096 is the buffer yamux's bufio.Reader actually hands down.
+ got := make([]byte, len(payload))
+ _, err = io.ReadFull(reader, got)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bytes.Equal(got, payload)).To(BeTrue())
+ })
+
+ It("presents consecutive messages as one continuous byte stream", func() {
+ clientWS, serverWS := wsPair()
+ writer := cluster.WebsocketConn(clientWS)
+ reader := cluster.WebsocketConn(serverWS)
+
+ Expect(reader.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+
+ for _, chunk := range []string{"abc", "", "de", "fghij"} {
+ _, err := writer.Write([]byte(chunk))
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // A read spanning several messages must be satisfied: a message
+ // boundary is not the end of the stream.
+ got := make([]byte, 10)
+ _, err := io.ReadFull(reader, got)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(got)).To(Equal("abcdefghij"))
+ })
+
+ It("never hands back a zero-length read for a zero-length message", func() {
+ clientWS, serverWS := wsPair()
+ writer := cluster.WebsocketConn(clientWS)
+ reader := cluster.WebsocketConn(serverWS)
+
+ Expect(reader.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed())
+
+ // An empty message carries nothing to return. Handing back (0, nil)
+ // would be legal for io.Reader but reads as a stalled stream to callers
+ // that loop on n, so the adapter waits for the next message instead.
+ _, err := writer.Write(nil)
+ Expect(err).ToNot(HaveOccurred())
+ _, err = writer.Write([]byte("xy"))
+ Expect(err).ToNot(HaveOccurred())
+
+ n, err := reader.Read(make([]byte, 8))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(n).To(Equal(2))
+ })
+
+ It("reports a clean peer close as io.EOF", func() {
+ clientWS, serverWS := wsPair()
+ reader := cluster.WebsocketConn(serverWS)
+
+ Expect(clientWS.WriteMessage(websocket.CloseMessage,
+ websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))).To(Succeed())
+
+ _, err := reader.Read(make([]byte, 8))
+ Expect(err).To(MatchError(io.EOF))
+ })
+
+ It("refuses a text message rather than desynchronising the stream", func() {
+ clientWS, serverWS := wsPair()
+ reader := cluster.WebsocketConn(serverWS)
+
+ Expect(clientWS.WriteMessage(websocket.TextMessage, []byte("not a frame"))).To(Succeed())
+
+ _, err := reader.Read(make([]byte, 32))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("want binary"))
+ })
+
+ It("satisfies net.Conn", func() {
+ clientWS, _ := wsPair()
+ var conn net.Conn = cluster.WebsocketConn(clientWS)
+
+ Expect(conn.LocalAddr()).ToNot(BeNil())
+ Expect(conn.RemoteAddr()).ToNot(BeNil())
+ })
+
+ It("enforces a write deadline, which yamux arms before every flush", func() {
+ clientWS, _ := wsPair()
+ conn := cluster.WebsocketConn(clientWS)
+
+ // Asserting that the setter returns nil would prove nothing: gorilla
+ // only records the deadline and applies it at the next flush. The write
+ // below is what shows the deadline reached the socket, and an adapter
+ // that swallowed the call would let a stalled peer block yamux's send
+ // loop forever instead of failing it.
+ Expect(conn.SetWriteDeadline(time.Now().Add(-time.Second))).To(Succeed())
+ _, err := conn.Write([]byte("x"))
+ Expect(err).To(HaveOccurred())
+ Expect(os.IsTimeout(err)).To(BeTrue(), "want a timeout, got %v", err)
+ })
+
+ It("enforces a read deadline, which is how a parked reader is unblocked", func() {
+ clientWS, _ := wsPair()
+ conn := cluster.WebsocketConn(clientWS)
+
+ Expect(conn.SetReadDeadline(time.Now().Add(-time.Second))).To(Succeed())
+ _, err := conn.Read(make([]byte, 8))
+ Expect(err).To(HaveOccurred())
+ Expect(os.IsTimeout(err)).To(BeTrue(), "want a timeout, got %v", err)
+ })
+
+ It("arms both directions from SetDeadline", func() {
+ clientWS, _ := wsPair()
+ conn := cluster.WebsocketConn(clientWS)
+
+ Expect(conn.SetDeadline(time.Now().Add(-time.Second))).To(Succeed())
+
+ _, err := conn.Read(make([]byte, 8))
+ Expect(os.IsTimeout(err)).To(BeTrue(), "read: want a timeout, got %v", err)
+ _, err = conn.Write([]byte("x"))
+ Expect(os.IsTimeout(err)).To(BeTrue(), "write: want a timeout, got %v", err)
+ })
+
+ It("lets a deadline be armed while another goroutine writes", func() {
+ // Task 5's relay arms an idle deadline from a supervisor goroutine while
+ // yamux's send loop writes. gorilla keeps the write deadline in a plain
+ // struct field, so this is a data race unless the adapter serialises it;
+ // the spec is here to be run under -race, where it would report one.
+ clientWS, _ := wsPair()
+ conn := cluster.WebsocketConn(clientWS)
+
+ done := make(chan struct{})
+ go func() {
+ defer GinkgoRecover()
+ defer close(done)
+ for i := 0; i < 200; i++ {
+ _, _ = conn.Write([]byte("ping"))
+ }
+ }()
+ for i := 0; i < 200; i++ {
+ _ = conn.SetWriteDeadline(time.Now().Add(time.Minute))
+ }
+ Eventually(done, "20s").Should(BeClosed())
+ })
+})
+
+var _ = Describe("Peer link payloads", func() {
+ It("carries a payload far larger than one yamux frame end to end", func() {
+ sessions := make(chan *yamux.Session, 1)
+ e := echo.New()
+ servePeerRoute(e, "peer-token", func(_ string, s *yamux.Session) { sessions <- s })
+ srv := httptest.NewServer(e)
+ DeferCleanup(srv.Close)
+
+ h := http.Header{}
+ h.Set("Authorization", "Bearer peer-token")
+ conn, _, err := websocket.DefaultDialer.Dial(
+ "ws"+strings.TrimPrefix(srv.URL, "http")+"/api/cluster/peer?id=peer-1", h)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ var serverSess *yamux.Session
+ Eventually(sessions, "5s").Should(Receive(&serverSess))
+
+ clientSess, err := yamux.Client(cluster.WebsocketConn(conn), nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = clientSess.Close() })
+
+ payload := make([]byte, 1<<20)
+ _, err = rand.Read(payload)
+ Expect(err).ToNot(HaveOccurred())
+
+ go func() {
+ defer GinkgoRecover()
+ st, e := clientSess.OpenStream(GinkgoT().Context())
+ if e != nil {
+ return
+ }
+ defer func() { _ = st.Close() }()
+ _, _ = io.Copy(st, bytes.NewReader(payload))
+ }()
+
+ received := make(chan []byte, 1)
+ go func() {
+ defer GinkgoRecover()
+ st, e := serverSess.AcceptStream()
+ if e != nil {
+ return
+ }
+ buf := make([]byte, len(payload))
+ if _, e := io.ReadFull(st, buf); e == nil {
+ received <- buf
+ }
+ }()
+
+ var got []byte
+ Eventually(received, "30s").Should(Receive(&got))
+ Expect(bytes.Equal(got, payload)).To(BeTrue())
+ })
+})
diff --git a/core/services/messaging/backend_install_progress.go b/core/services/messaging/backend_install_progress.go
index 268ef86b909b..52065bf64c7f 100644
--- a/core/services/messaging/backend_install_progress.go
+++ b/core/services/messaging/backend_install_progress.go
@@ -11,10 +11,11 @@ const (
PhaseStarting = "starting" // worker is spawning the gRPC backend process
)
-// BackendInstallProgressEvent is the wire payload published by a worker to
-// nodes..backend.install..progress while a long-running install
-// is in flight. Transient: dropped events are acceptable, the master relies
-// on BackendInstallReply for ground truth on success/failure.
+// BackendInstallProgressEvent is the wire payload a worker writes as a progress
+// line of its backend.install and backend.upgrade responses while a
+// long-running install is in flight. Transient: a line the frontend cannot read
+// is acceptable, and BackendInstallReply is the ground truth on
+// success/failure.
//
// Phase holds one of the Phase* constants above.
type BackendInstallProgressEvent struct {
@@ -27,10 +28,3 @@ type BackendInstallProgressEvent struct {
Percentage float64 `json:"percentage"`
Phase string `json:"phase,omitempty"`
}
-
-// SubjectNodeBackendInstallProgress returns the NATS subject for transient
-// progress events emitted by a worker during a single backend.install run.
-// Per-op so multiple concurrent installs on the same node never alias.
-func SubjectNodeBackendInstallProgress(nodeID, opID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.install." + sanitizeSubjectToken(opID) + ".progress"
-}
diff --git a/core/services/messaging/backend_install_progress_test.go b/core/services/messaging/backend_install_progress_test.go
index ec45f4619f69..e57c5505f788 100644
--- a/core/services/messaging/backend_install_progress_test.go
+++ b/core/services/messaging/backend_install_progress_test.go
@@ -2,7 +2,6 @@ package messaging_test
import (
"encoding/json"
- "strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -26,23 +25,6 @@ var _ = Describe("Phase constants", func() {
})
var _ = Describe("BackendInstallProgress", func() {
- Context("SubjectNodeBackendInstallProgress", func() {
- It("composes the per-op progress subject", func() {
- Expect(messaging.SubjectNodeBackendInstallProgress("node-abc", "op-123")).
- To(Equal("nodes.node-abc.backend.install.op-123.progress"))
- })
-
- It("sanitizes NATS-reserved characters in node and op tokens", func() {
- // '.' is the NATS hierarchy delimiter, '*' and '>' are wildcards,
- // and whitespace must be stripped - sanitizeSubjectToken replaces
- // all of them with '-'. The resulting subject must still parse as
- // exactly six hierarchy segments: nodes//backend/install//progress.
- subj := messaging.SubjectNodeBackendInstallProgress("a.b c", "x.y z")
- Expect(subj).ToNot(ContainSubstring(" "))
- Expect(strings.Count(subj, ".")).To(Equal(5))
- })
- })
-
Context("BackendInstallProgressEvent", func() {
It("JSON round-trips with all known fields", func() {
ev := messaging.BackendInstallProgressEvent{
diff --git a/core/services/messaging/subjects.go b/core/services/messaging/subjects.go
index c1f4cf8bfbab..3bb8a49eb57d 100644
--- a/core/services/messaging/subjects.go
+++ b/core/services/messaging/subjects.go
@@ -141,26 +141,29 @@ func SubjectResponseCancel(responseID string) string {
return subjectResponseCancelPrefix + sanitizeSubjectToken(responseID) + ".cancel"
}
-// Node Backend Lifecycle (Pub/Sub — targeted to specific nodes)
+// Node Backend Lifecycle
//
-// These subjects control the backend *process* lifecycle on a serve-backend node,
-// mirroring how the local ModelLoader uses startProcess() / deleteProcess().
+// The frontend's control plane no longer travels on NATS. The ten verbs that
+// drove a worker's backend and model lifecycle are HTTP routes under
+// workerctl.Prefix, served on the worker's own loopback server and reached
+// through its tunnel, so a subject builder for any of them would be a subject
+// nothing publishes and nothing subscribes to.
//
-// Model loading (LoadModel gRPC) is done via direct gRPC calls to the node's
-// address — no NATS needed for that, same as local mode.
+// ONE survives: backend.stop, and only for AGENT workers. They hold no tunnel,
+// so they have no control plane to serve, and they subscribe to it to drop the
+// MCP sessions cached for a backend that is going away. See
+// nodes.RemoteUnloaderAdapter.stopBackend for the split, and
+// core/cli/agent_worker.go for the subscriber.
+//
+// The request and reply types below are UNCHANGED and still live here: they are
+// the wire format of the control routes, byte for byte what the subjects
+// carried, so a worker and a frontend from different releases still understand
+// each other.
const (
subjectNodePrefix = "nodes."
)
-// SubjectNodeBackendInstall tells a worker node to install a backend and start its gRPC process.
-// Uses NATS request-reply: the SmartRouter sends the request, the worker installs
-// the backend from gallery (if not already installed), starts the gRPC process,
-// and replies when ready.
-func SubjectNodeBackendInstall(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.install"
-}
-
-// BackendInstallRequest is the payload for a backend.install NATS request.
+// BackendInstallRequest is the payload for a backend.install control request.
type BackendInstallRequest struct {
Backend string `json:"backend"`
ModelID string `json:"model_id,omitempty"`
@@ -178,37 +181,36 @@ type BackendInstallRequest struct {
ReplicaIndex int32 `json:"replica_index,omitempty"`
// Force is retained on the wire only for backward compatibility with
// pre-2026-05-08 masters that did not know about backend.upgrade. New
- // callers MUST send to SubjectNodeBackendUpgrade instead. Workers continue
+ // callers MUST use workerctl.PathBackendUpgrade instead. Workers continue
// to honor Force=true here so a rolling update with new master + old
// worker still works (the master's install fallback path also uses this
- // when backend.upgrade returns nats.ErrNoResponders).
+ // when the worker answers that it does not serve the upgrade verb).
Force bool `json:"force,omitempty"`
- // OpID identifies the admin-side operation. When non-empty the worker
- // publishes BackendInstallProgressEvent values to
- // SubjectNodeBackendInstallProgress(nodeID, OpID) while the install is
- // running, debounced to roughly 250ms. Empty means the caller is a
- // reconciler-driven retry that does not need progress streamed.
+ // OpID identifies the admin-side operation. It travels so the worker can
+ // name the operation on the BackendInstallProgressEvent values it writes
+ // into the install response ahead of the reply, debounced to roughly 250ms.
+ // Empty means the caller is a reconciler-driven retry that does not need
+ // progress streamed.
OpID string `json:"op_id,omitempty"`
}
-// BackendInstallReply is the response from a backend.install NATS request.
+// BackendInstallReply is the response from a backend.install control request.
type BackendInstallReply struct {
- Success bool `json:"success"`
- Address string `json:"address,omitempty"` // gRPC address of the backend process (host:port)
- Error string `json:"error,omitempty"`
-}
-
-// SubjectNodeBackendUpgrade tells a worker node to force-reinstall a backend
-// from the gallery, stop every running process for that backend, and restart.
-// Uses NATS request-reply with a long deadline (gallery image pulls can take
-// many minutes on slow links). Routine model loads use SubjectNodeBackendInstall
-// instead — this subject exists so the slow path doesn't head-of-line-block
-// the fast one through a shared subscription goroutine.
-func SubjectNodeBackendUpgrade(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.upgrade"
-}
-
-// BackendUpgradeRequest is the payload for a backend.upgrade NATS request.
+ Success bool `json:"success"`
+ // WorkerLocalAddress is where the backend process listens ON THE WORKER,
+ // which is a loopback address. It is not dialable from the frontend and
+ // never was meant to be read that way: the frontend takes its PORT and
+ // names it as the target of a stream on that worker's tunnel, and the
+ // worker dials its own loopback there.
+ //
+ // The json tag stays "address" so a worker and a frontend from different
+ // releases still understand each other. An older worker sends its
+ // advertised host here; only the port is read, and the port is the same.
+ WorkerLocalAddress string `json:"address,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// BackendUpgradeRequest is the payload for a backend.upgrade control request.
// It is intentionally a strict subset of BackendInstallRequest — there is no
// Force field because the upgrade subject IS the force semantics; no ModelID
// because upgrade is backend-scoped (it stops every replica using the binary
@@ -223,13 +225,9 @@ type BackendUpgradeRequest struct {
// but the field lets future per-replica metadata (e.g. progress reporting
// scoped to a slot) ride the same wire without a v3 type.
ReplicaIndex int32 `json:"replica_index,omitempty"`
- // OpID identifies the admin-side operation. When non-empty the worker
- // publishes BackendInstallProgressEvent values to
- // SubjectNodeBackendInstallProgress(nodeID, OpID) while the force-reinstall
- // runs, so the master can stream per-node progress for upgrades exactly as
- // it already does for installs (an upgrade IS a force-reinstall, so the
- // install-progress subject is reused rather than minting a new one — no new
- // NATS permission or rolling-update compat surface). Empty on legacy callers.
+ // OpID identifies the admin-side operation, so an upgrade streams per-node
+ // progress in its own response exactly as an install does. Empty on legacy
+ // callers.
OpID string `json:"op_id,omitempty"`
}
@@ -249,16 +247,10 @@ type BackendUpgradeReply struct {
ReportsStoppedProcesses bool `json:"reports_stopped_processes,omitempty"`
}
-// SubjectNodeBackendList queries a worker node for its installed backends.
-// Uses NATS request-reply.
-func SubjectNodeBackendList(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.list"
-}
-
-// BackendListRequest is the payload for a backend.list NATS request.
+// BackendListRequest is the payload for a backend.list control request.
type BackendListRequest struct{}
-// BackendListReply is the response from a backend.list NATS request.
+// BackendListReply is the response from a backend.list control request.
type BackendListReply struct {
Backends []NodeBackendInfo `json:"backends"`
Error string `json:"error,omitempty"`
@@ -287,21 +279,17 @@ type BackendStopRequest struct {
Force bool `json:"force,omitempty"`
}
-// SubjectNodeBackendStop tells a worker node to stop its gRPC backend process.
-// Equivalent to the local deleteProcess(). The node will:
-// 1. Best-effort bounded Free() via gRPC (unless Force is true)
-// 2. Kill the backend process
-// 3. Can be restarted via another backend.start event.
+// SubjectNodeBackendStop tells an AGENT worker that a backend is going away, so
+// it can close the MCP sessions it cached for that backend.
+//
+// It is the one node subject left, and it is addressed only to agent nodes. A
+// BACKEND worker takes its stop on workerctl.PathBackendStop over its tunnel,
+// where it also kills the process and recycles the port; an agent worker runs
+// no backend processes and only needs to hear that one went.
func SubjectNodeBackendStop(nodeID string) string {
return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.stop"
}
-// SubjectNodeModelStop targets one supervisor process and acknowledges only
-// after that process has exited and its worker-side resources are released.
-func SubjectNodeModelStop(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".model.stop"
-}
-
type ModelStopRequest struct {
ModelName string `json:"model_name"`
ProcessKey string `json:"process_key"`
@@ -319,18 +307,12 @@ type ModelStopReply struct {
Error string `json:"error,omitempty"`
}
-// SubjectNodeBackendDelete tells a worker node to delete a backend (stop + remove files).
-// Uses NATS request-reply.
-func SubjectNodeBackendDelete(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".backend.delete"
-}
-
-// BackendDeleteRequest is the payload for a backend.delete NATS request.
+// BackendDeleteRequest is the payload for a backend.delete control request.
type BackendDeleteRequest struct {
Backend string `json:"backend"`
}
-// BackendDeleteReply is the response from a backend.delete NATS request.
+// BackendDeleteReply is the response from a backend.delete control request.
type BackendDeleteReply struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
@@ -353,57 +335,33 @@ type BackendDeleteReply struct {
ReportsStoppedProcesses bool `json:"reports_stopped_processes,omitempty"`
}
-// SubjectNodeModelUnload tells a worker node to unload a model (gRPC Free) without killing the backend.
-// Uses NATS request-reply.
-func SubjectNodeModelUnload(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".model.unload"
-}
-
-// ModelUnloadRequest is the payload for a model.unload NATS request.
+// ModelUnloadRequest is the payload for a model.unload control request.
type ModelUnloadRequest struct {
ModelName string `json:"model_name"`
Address string `json:"address,omitempty"` // gRPC address of the backend process to unload from
}
-// ModelUnloadReply is the response from a model.unload NATS request.
+// ModelUnloadReply is the response from a model.unload control request.
type ModelUnloadReply struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
-// SubjectNodeModelDelete tells a worker node to delete model files from disk.
-// Uses NATS request-reply.
-func SubjectNodeModelDelete(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".model.delete"
-}
-
-// ModelDeleteRequest is the payload for a model.delete NATS request.
+// ModelDeleteRequest is the payload for a model.delete control request.
type ModelDeleteRequest struct {
ModelName string `json:"model_name"`
}
-// ModelDeleteReply is the response from a model.delete NATS request.
+// ModelDeleteReply is the response from a model.delete control request.
type ModelDeleteReply struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
-// SubjectNodeModelsRunning asks a worker node which model backend processes it
-// currently has running. Uses NATS request-reply.
-//
-// This is the authoritative answer to "is this replica still alive". The worker
-// owns the process table, so unlike a health probe against the backend's own
-// serving port, its reply does not depend on whether that backend happens to be
-// busy: a model mid-generation cannot answer a gRPC health check for minutes at
-// a time, but the worker answers immediately either way.
-func SubjectNodeModelsRunning(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".models.running"
-}
-
-// ModelsRunningRequest is the payload for a models.running NATS request.
+// ModelsRunningRequest is the payload for a models.running control request.
type ModelsRunningRequest struct{}
-// ModelsRunningReply is the response from a models.running NATS request.
+// ModelsRunningReply is the response from a models.running control request.
type ModelsRunningReply struct {
Models []RunningModelInfo `json:"models"`
Error string `json:"error,omitempty"`
@@ -418,38 +376,9 @@ type RunningModelInfo struct {
Address string `json:"address,omitempty"`
}
-// SubjectNodeStop tells a serve-backend node to shut down entirely
-// (deregister + exit). The node will not restart the backend process.
-func SubjectNodeStop(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".stop"
-}
-
-// File Staging (Request-Reply — targeted to specific nodes)
-// These subjects use request-reply for synchronous file operations.
-
-// SubjectNodeFilesEnsure tells a serve-backend node to download an S3 key to its local cache.
-// Reply: {local_path, error}
-func SubjectNodeFilesEnsure(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".files.ensure"
-}
-
-// SubjectNodeFilesStage tells a serve-backend node to upload a local file to S3.
-// Reply: {key, error}
-func SubjectNodeFilesStage(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".files.stage"
-}
-
-// SubjectNodeFilesTemp tells a serve-backend node to allocate a temp file.
-// Reply: {local_path, error}
-func SubjectNodeFilesTemp(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".files.temp"
-}
-
-// SubjectNodeFilesListDir tells a serve-backend node to list files in a directory.
-// Reply: {files: [...], error}
-func SubjectNodeFilesListDir(nodeID string) string {
- return subjectNodePrefix + sanitizeSubjectToken(nodeID) + ".files.listdir"
-}
+// File staging is no longer carried here. The four nodes..files.* subjects
+// are HTTP routes under workerctl.Prefix, served on the worker's own server and
+// reached through its tunnel, so no subject is minted for them.
// Cache Invalidation (Pub/Sub — broadcast to all instances)
const (
diff --git a/core/services/messaging/subjects_upgrade_test.go b/core/services/messaging/subjects_upgrade_test.go
index e60369cfca0a..e1a059fac98d 100644
--- a/core/services/messaging/subjects_upgrade_test.go
+++ b/core/services/messaging/subjects_upgrade_test.go
@@ -7,15 +7,19 @@ import (
"github.com/mudler/LocalAI/core/services/messaging"
)
-var _ = Describe("SubjectNodeBackendUpgrade", func() {
- It("returns the per-node upgrade subject", func() {
- Expect(messaging.SubjectNodeBackendUpgrade("abc")).
- To(Equal("nodes.abc.backend.upgrade"))
+// The surviving node subject. It is written out BY HAND and not derived from
+// the builder: an agent worker built from another commit subscribes to this
+// literal, and a renamed subject is silence that looks exactly like a worker
+// that never started.
+var _ = Describe("SubjectNodeBackendStop", func() {
+ It("returns the per-node stop subject an agent worker subscribes to", func() {
+ Expect(messaging.SubjectNodeBackendStop("abc")).
+ To(Equal("nodes.abc.backend.stop"))
})
It("sanitizes reserved NATS tokens in the node id", func() {
- Expect(messaging.SubjectNodeBackendUpgrade("a.b*c")).
- To(Equal("nodes.a-b-c.backend.upgrade"))
+ Expect(messaging.SubjectNodeBackendStop("a.b*c")).
+ To(Equal("nodes.a-b-c.backend.stop"))
})
})
diff --git a/core/services/messaging/subjects_wire_test.go b/core/services/messaging/subjects_wire_test.go
new file mode 100644
index 000000000000..2c90254e8ff5
--- /dev/null
+++ b/core/services/messaging/subjects_wire_test.go
@@ -0,0 +1,46 @@
+package messaging
+
+import (
+ "encoding/json"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// BackendInstallReply.WorkerLocalAddress was called Address until workers
+// stopped advertising. The Go field was renamed so no reader takes it for a
+// dial target; the wire key was deliberately NOT renamed, because a worker and
+// a frontend from different releases have to keep understanding each other
+// across a rolling upgrade.
+//
+// That is a cross-version compatibility property resting on one struct tag, and
+// a struct tag nobody asserts is a property nobody has. Renaming just the tags
+// left the whole suite green when this was written.
+var _ = Describe("backend.install reply wire format", func() {
+ It("writes the address under the key an older frontend reads", func() {
+ out, err := json.Marshal(BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:50052"})
+ Expect(err).ToNot(HaveOccurred())
+
+ var raw map[string]any
+ Expect(json.Unmarshal(out, &raw)).To(Succeed())
+ Expect(raw).To(HaveKeyWithValue("address", "127.0.0.1:50052"))
+ Expect(raw).ToNot(HaveKey("worker_local_address"),
+ "renaming the wire key would make every install reply unreadable to a frontend of another release")
+ })
+
+ It("reads the address an older worker sends", func() {
+ // An older worker puts its ADVERTISED host here. Only the port is used,
+ // and the port is the same, so accepting it is both harmless and the
+ // thing that keeps a mixed fleet working.
+ var reply BackendInstallReply
+ Expect(json.Unmarshal([]byte(`{"success":true,"address":"worker-1:50052"}`), &reply)).To(Succeed())
+ Expect(reply.Success).To(BeTrue())
+ Expect(reply.WorkerLocalAddress).To(Equal("worker-1:50052"))
+ })
+
+ It("omits the address when the install failed", func() {
+ out, err := json.Marshal(BackendInstallReply{Success: false, Error: "boom"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(out)).ToNot(ContainSubstring("address"))
+ })
+})
diff --git a/core/services/nodes/authenticated_routes_test.go b/core/services/nodes/authenticated_routes_test.go
new file mode 100644
index 000000000000..215e2bead38d
--- /dev/null
+++ b/core/services/nodes/authenticated_routes_test.go
@@ -0,0 +1,112 @@
+package nodes
+
+import (
+ "net"
+ "net/http"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("extra authenticated routes on the worker HTTP server", func() {
+ const token = "s3cr3t"
+
+ var (
+ srv *http.Server
+ base string
+ )
+
+ BeforeEach(func() {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ dir := GinkgoT().TempDir()
+ srv, err = StartFileTransferServerWithRoutes(lis, dir, dir, dir, token, 0, nil,
+ &AuthenticatedRoutes{
+ Prefix: "/v1/control/",
+ Register: func(mux *http.ServeMux) {
+ mux.HandleFunc("/v1/control/ping", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("pong"))
+ })
+ },
+ })
+ Expect(err).NotTo(HaveOccurred())
+ base = "http://" + lis.Addr().String()
+ DeferCleanup(func() { ShutdownFileTransferServer(srv) })
+ })
+
+ get := func(path, bearer string) *http.Response {
+ GinkgoHelper()
+ req, err := http.NewRequest(http.MethodGet, base+path, nil)
+ Expect(err).NotTo(HaveOccurred())
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ return resp
+ }
+
+ It("serves a registered extra route to a caller carrying the token", func() {
+ Expect(get("/v1/control/ping", token).StatusCode).To(Equal(http.StatusOK))
+ })
+
+ It("refuses an unauthenticated request on an extra route", func() {
+ // The control plane must not be a second authentication path: an extra
+ // route that forgot its own check would be an unauthenticated command
+ // on the boundary the tunnel exposes.
+ Expect(get("/v1/control/ping", "").StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("refuses a wrong token on an extra route", func() {
+ Expect(get("/v1/control/ping", "wrong").StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("checks the token before the route exists, so an unknown control path leaks nothing", func() {
+ Expect(get("/v1/control/no-such-verb", "").StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("leaves the file routes reachable alongside the extra ones", func() {
+ Expect(get("/healthz", "").StatusCode).To(Equal(http.StatusOK))
+ })
+
+ It("refuses to start when a route set names no prefix", func() {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = lis.Close() })
+ dir := GinkgoT().TempDir()
+ _, err = StartFileTransferServerWithRoutes(lis, dir, dir, dir, token, 0, nil,
+ &AuthenticatedRoutes{Register: func(*http.ServeMux) {}})
+ Expect(err).To(MatchError(ContainSubstring("prefix")))
+ })
+
+ It("refuses to start when a route set names no registrar", func() {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = lis.Close() })
+ dir := GinkgoT().TempDir()
+ _, err = StartFileTransferServerWithRoutes(lis, dir, dir, dir, token, 0, nil,
+ &AuthenticatedRoutes{Prefix: "/v1/control/"})
+ Expect(err).To(MatchError(ContainSubstring("registrar")))
+ })
+
+ It("mounts nothing when no route set is given", func() {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ dir := GinkgoT().TempDir()
+ bare, err := StartFileTransferServerWithRoutes(lis, dir, dir, dir, token, 0, nil, nil)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { ShutdownFileTransferServer(bare) })
+
+ req, err := http.NewRequest(http.MethodGet, "http://"+lis.Addr().String()+"/v1/control/ping", nil)
+ Expect(err).NotTo(HaveOccurred())
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusNotFound))
+ Expect(strings.TrimSpace(resp.Status)).NotTo(BeEmpty())
+ })
+})
diff --git a/core/services/nodes/backend_client_factory_test.go b/core/services/nodes/backend_client_factory_test.go
new file mode 100644
index 000000000000..15adeb4f3117
--- /dev/null
+++ b/core/services/nodes/backend_client_factory_test.go
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: MIT
+
+package nodes
+
+import (
+ "context"
+ "net"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ grpcpkg "github.com/mudler/LocalAI/pkg/grpc"
+)
+
+var _ = Describe("The backend client factory", func() {
+ Describe("without a worker tunnel dialer", func() {
+ It("refuses to build a client for a node rather than dialling its address", func() {
+ // The whole point. A factory that answered here with a client
+ // pointed at the raw address would work on a single-host developer
+ // setup and fail against every worker that has no inbound port,
+ // which is the worst way for this to behave.
+ f := &tokenClientFactory{token: "tok"}
+ _, err := f.NewClientForNode("node-1", "10.0.0.1:41000", false)
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("offers no direct-dial constructor for anything to reach for", func() {
+ // Structural, not documented. A NewClient alongside NewClientForNode
+ // would be reachable from every call site that holds an address,
+ // which is all of them, and reintroducing the bypass would then be
+ // a one-word edit that compiles and passes every other spec.
+ var factory any = &tunnelClientFactory{}
+ _, hasDirectDial := factory.(interface {
+ NewClient(address string, parallel bool) grpcpkg.Backend
+ })
+ Expect(hasDirectDial).To(BeFalse())
+ })
+
+ It("refuses to be constructed at all", func() {
+ _, err := NewTunnelClientFactory("tok", nil)
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+ })
+
+ Describe("with a worker tunnel dialer", func() {
+ It("builds a client that reaches the backend through the node's dialer", func() {
+ // The proof is that the client's transport is the one this factory
+ // was given: it carries bytes from a listener that the address in
+ // the request never names.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = listener.Close() })
+
+ asked := make(chan string, 4)
+ f, err := NewTunnelClientFactory("", func(nodeID string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(ctx context.Context, addr string) (net.Conn, error) {
+ asked <- nodeID + "|" + addr
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", listener.Addr().String())
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ client, err := f.NewClientForNode("node-1", "10.255.255.1:41000", false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // The address is unroutable on purpose: only a client that used the
+ // dialer can reach anything at all. The health check itself fails,
+ // because nothing on the far side speaks gRPC; what it proves is
+ // which transport was asked.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() {
+ defer GinkgoRecover()
+ _, _ = client.HealthCheck(ctx)
+ }()
+ Eventually(asked, "10s").Should(Receive(Equal("node-1|10.255.255.1:41000")))
+ })
+
+ It("refuses a request with no node id", func() {
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return func(ctx context.Context, addr string) (net.Conn, error) {
+ return d.DialContext(ctx, "tcp", addr)
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ _, err = f.NewClientForNode("", "10.0.0.1:41000", false)
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("refuses when the dialer has none for that node", func() {
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return nil
+ })
+ Expect(err).ToNot(HaveOccurred())
+ _, err = f.NewClientForNode("node-1", "10.0.0.1:41000", false)
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+ })
+})
+
+var _ = Describe("the host used to address a worker's own HTTP server", func() {
+ It("uses the registered address when the worker reports one", func() {
+ Expect(WorkerHTTPHost("node-1", "10.0.0.5:8080")).To(Equal("10.0.0.5:8080"))
+ })
+
+ It("still produces a host for a tunnel-only worker that reports none", func() {
+ // Task 7 removes the worker's inbound listeners, at which point a
+ // worker has no address to report. Refusing here would refuse exactly
+ // the workers the tunnel exists for, and the guards that used to do
+ // that returned 502 "node has no HTTP address".
+ host := WorkerHTTPHost("node-1", "")
+ Expect(host).ToNot(BeEmpty())
+ Expect(host).To(ContainSubstring("node-1"))
+ })
+
+ It("produces a host that cannot resolve, so it can never become a dial", func() {
+ // The value fills a URL's host component and nothing else. Making it
+ // unresolvable is what stops a later refactor connecting to it by
+ // accident: .invalid is reserved by RFC 2606 and resolves nowhere.
+ host := WorkerHTTPHost("node-1", "")
+ hostname, _, err := net.SplitHostPort(host)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(hostname).To(HaveSuffix(".invalid"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, err = net.DefaultResolver.LookupHost(ctx, hostname)
+ Expect(err).To(HaveOccurred())
+ })
+})
diff --git a/core/services/nodes/control_client.go b/core/services/nodes/control_client.go
new file mode 100644
index 000000000000..6c943892b426
--- /dev/null
+++ b/core/services/nodes/control_client.go
@@ -0,0 +1,307 @@
+package nodes
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/mudler/xlog"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+ "github.com/mudler/LocalAI/pkg/httpclient"
+)
+
+// ErrWorkerControlUnsupported reports that the worker answered, and does not
+// serve that control verb.
+//
+// It is a deployment fact and not a verdict about anything: the worker is
+// running a build older than this frontend, so a path this frontend knows is a
+// 404 there. The one caller that may act on it is the upgrade fallback, which
+// re-issues the legacy force-install the older build does understand.
+//
+// It is a SPECIALISATION of ErrWorkerUnroutable for the same reason
+// ErrNoWorkerDialer is: everything that DELETES a node_models row makes exactly
+// one check, and a 404 must not pass it. The worker spoke, but it said nothing
+// about any backend, and reaping on it would evict healthy work over a version
+// skew. Callers that want the fallback match this sentinel specifically, which
+// ErrWorkerUnroutable does not imply in that direction.
+var ErrWorkerControlUnsupported = fmt.Errorf("%w: the worker does not serve that control verb", ErrWorkerUnroutable)
+
+// ControlClient issues the frontend's control RPCs to a worker over that
+// worker's tunnel.
+//
+// It is the frontend half of core/services/workerctl: the worker mounts those
+// paths on the loopback HTTP server it already runs, and this reaches them
+// through the `http` stream tag, so a control RPC to a worker another replica
+// holds is relayed exactly like an inference request.
+//
+// There is one of these per frontend rather than one per verb, because the
+// mapping from a failed RPC onto the four conditions this system must never
+// confuse belongs in ONE place. See controlFailure.
+type ControlClient struct {
+ // dialFor supplies the transport for one worker. It is per node because a
+ // worker is reached over ITS OWN tunnel and an http.Transport carries one
+ // DialContext, so a single shared transport could only ever reach one
+ // worker. nil means no tunnel dialer is wired and every call is refused;
+ // see ErrNoWorkerDialer for why that is not a fallback to a direct dial.
+ dialFor WorkerNetDialerFor
+ token string
+
+ // clients caches one *http.Client per node, so a verb issued twice in a
+ // row reuses the tunnel stream its transport already holds instead of
+ // opening a new one.
+ //
+ // Entries are never pruned, and that is judged rather than overlooked: the
+ // map is bounded by the number of distinct workers this frontend has ever
+ // commanded, which is bounded by the fleet, and a departed worker's entry
+ // holds a map slot plus a transport whose idle connections IdleConnTimeout
+ // reclaims. It is the same shape, and would need the same missing
+ // node-departure signal to fix, as HTTPFileStager.clients.
+ clientsMu sync.Mutex
+ clients map[string]*http.Client
+}
+
+// NewControlClient returns the control client for the workers dialFor can
+// reach, authenticating with the deployment's registration token.
+func NewControlClient(dialFor WorkerNetDialerFor, token string) *ControlClient {
+ return &ControlClient{dialFor: dialFor, token: token, clients: map[string]*http.Client{}}
+}
+
+// clientFor returns the HTTP client that reaches one worker, building it on
+// first use.
+//
+// The transport mirrors HTTPFileStager.clientFor, which is the other consumer
+// of a worker's own HTTP server, and differs only where control traffic
+// differs. HTTP/2 stays OFF for the reason it always was: its flow control
+// stalls large transfers, and the install verb streams for minutes. The
+// stager's 256 KB socket buffers are dropped because a control body is a
+// handful of kilobytes and two 256 KB buffers per worker would be paid for
+// every node in the fleet.
+//
+// The net.Dialer's connect timeout and keepalive have nothing to act on here:
+// there is no TCP connect to time out, and liveness on the link is the yamux
+// session's keepalive rather than the socket's. No client.Timeout is set
+// either, because it would bound the response BODY, and the install verb's
+// body stays open for as long as the install runs. What bounds a call is the
+// context its caller passes.
+func (c *ControlClient) clientFor(nodeID string) (*http.Client, error) {
+ if c == nil || c.dialFor == nil {
+ return nil, fmt.Errorf("control rpc to node %q: %w", nodeID, ErrNoWorkerDialer)
+ }
+ c.clientsMu.Lock()
+ defer c.clientsMu.Unlock()
+ if cl, ok := c.clients[nodeID]; ok {
+ return cl, nil
+ }
+ dial := c.dialFor(nodeID)
+ if dial == nil {
+ return nil, fmt.Errorf("control rpc to node %q: %w", nodeID, ErrNoWorkerDialer)
+ }
+ transport := &http.Transport{
+ DialContext: dial,
+ ForceAttemptHTTP2: false, // HTTP/2 flow control can stall a long streaming verb
+ MaxIdleConns: 10,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ }
+ cl := httpclient.New(httpclient.WithTransport(transport))
+ c.clients[nodeID] = cl
+ return cl, nil
+}
+
+// Call issues one control RPC and decodes its reply. reply may be nil for the
+// verbs that answer 204.
+//
+// A reply that arrives with its Error field set is NOT an error here: the
+// worker answered, and reading its answer is the caller's job. Only a failure
+// to reach the worker, or an answer this frontend cannot read, comes back as an
+// error, and every one of those is mapped by controlFailure.
+func (c *ControlClient) Call(ctx context.Context, nodeID, path string, req, reply any) error {
+ resp, err := c.do(ctx, nodeID, path, req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusNoContent || reply == nil {
+ // A verb that answers 204 sends no body at all; a caller that asked for
+ // no reply may still have been sent one, and draining it is what lets
+ // the transport keep the tunnel stream for the next verb instead of
+ // tearing it down.
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxControlErrorBodyBytes))
+ return nil
+ }
+ if err := json.NewDecoder(resp.Body).Decode(reply); err != nil {
+ // A body this frontend cannot read is not the worker's verdict about
+ // anything, so it must not be reported as one.
+ return controlFailure(ctx, nodeID, fmt.Errorf("decoding the %s reply: %w", path, err))
+ }
+ return nil
+}
+
+// CallStreaming issues a control RPC whose response is an NDJSON envelope
+// stream, invoking onProgress for each progress line and decoding the single
+// terminal reply line into reply. onProgress may be nil.
+//
+// onProgress runs SYNCHRONOUSLY, on this goroutine. The NATS carrier ran each
+// progress callback on a goroutine of its own because a slow callback there
+// stalled the one reader thread every worker's events arrived on; here the only
+// thing a slow callback holds up is this request's own body, which is the
+// caller's business. Dropping the guard is also what makes the events arrive in
+// the order the worker sent them.
+func (c *ControlClient) CallStreaming(ctx context.Context, nodeID, path string,
+ req, reply any, onProgress func(messaging.BackendInstallProgressEvent)) error {
+ resp, err := c.do(ctx, nodeID, path, req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ dec := json.NewDecoder(resp.Body)
+ var terminal json.RawMessage
+ for terminal == nil {
+ var env workerctl.Envelope
+ decErr := dec.Decode(&env)
+ if decErr != nil {
+ // EOF before a reply line is the tunnel dying mid-verb, and reading
+ // it as "the install failed" is precisely the collapse this phase
+ // exists to prevent: nothing was learned about the backend, so this
+ // is unroutable and a caller may not act on it.
+ if errors.Is(decErr, io.EOF) {
+ decErr = fmt.Errorf("the %s stream ended before its reply line: %w", path, io.ErrUnexpectedEOF)
+ }
+ return controlFailure(ctx, nodeID, decErr)
+ }
+ if env.Reply != nil {
+ terminal = env.Reply
+ break
+ }
+ if env.Progress == nil || onProgress == nil {
+ continue
+ }
+ var ev messaging.BackendInstallProgressEvent
+ if err := json.Unmarshal(env.Progress, &ev); err != nil {
+ // Progress is transient by contract, so a line this frontend cannot
+ // read costs a tick and never the operation.
+ xlog.Debug("unreadable control progress line", "node", nodeID, "path", path, "error", err)
+ continue
+ }
+ onProgress(ev)
+ }
+ if reply == nil {
+ return nil
+ }
+ if err := json.Unmarshal(terminal, reply); err != nil {
+ return controlFailure(ctx, nodeID, fmt.Errorf("decoding the %s reply line: %w", path, err))
+ }
+ return nil
+}
+
+// do issues the request and returns the response for any status this frontend
+// can read a body from. Every other outcome is already mapped.
+//
+// The caller owns closing the body.
+func (c *ControlClient) do(ctx context.Context, nodeID, path string, req any) (*http.Response, error) {
+ client, err := c.clientFor(nodeID)
+ if err != nil {
+ // ErrNoWorkerDialer already carries ErrWorkerUnroutable, so it must not
+ // go through controlFailure, which would wrap the umbrella twice.
+ return nil, err
+ }
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, controlFailure(ctx, nodeID, fmt.Errorf("encoding the %s request: %w", path, err))
+ }
+
+ // The host is a name that resolves nowhere. What carries the request is the
+ // transport's DialContext, which opens a stream on this worker's tunnel;
+ // the host exists because an http.Request needs one, and it names the node
+ // so a log line is diagnosable. See WorkerHTTPHost.
+ url := "http://" + WorkerHTTPHost(nodeID, "") + path
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, controlFailure(ctx, nodeID, fmt.Errorf("building the %s request: %w", path, err))
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ if c.token != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+c.token)
+ }
+
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return nil, controlFailure(ctx, nodeID, err)
+ }
+
+ switch {
+ case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusNoContent:
+ return resp, nil
+ case resp.StatusCode == http.StatusNotFound:
+ // The worker answered, about ITSELF rather than about a backend: it is
+ // older than this frontend and serves no such verb. The catch-all under
+ // the control prefix is what makes this distinguishable from a proxy's
+ // bare 404.
+ _ = resp.Body.Close()
+ return nil, fmt.Errorf("control rpc %s to node %q: %w", path, nodeID, ErrWorkerControlUnsupported)
+ default:
+ // A verb's own failure arrives as 200 with Error set, so a non-2xx is
+ // the worker failing to serve the request rather than answering it: a
+ // body it could not read, a method it refuses, a handler that panicked.
+ // None of those is evidence about a backend.
+ detail := readErrorBody(resp)
+ _ = resp.Body.Close()
+ return nil, controlFailure(ctx, nodeID, fmt.Errorf("the %s verb answered HTTP %d: %s", path, resp.StatusCode, detail))
+ }
+}
+
+// maxControlErrorBodyBytes bounds how much of a non-2xx body reaches a log
+// line. The body is written by the worker and lands in this frontend's logs, so
+// it is bounded for the same reason the worker bounds the path it echoes.
+const maxControlErrorBodyBytes = 512
+
+// readErrorBody reads the diagnostic text off a non-2xx control response.
+func readErrorBody(resp *http.Response) string {
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxControlErrorBodyBytes))
+ if err != nil {
+ return ""
+ }
+ return string(bytes.TrimSpace(raw))
+}
+
+// controlFailure maps a control RPC's failure onto exactly one of the four
+// conditions this system must never confuse, and it is the only place that
+// mapping is made.
+//
+// The rule it enforces, stated as the code enforces it rather than as advice: a
+// WORKER'S ANSWER passes through unwrapped so cluster.IsWorkerAnswer still sees
+// it and a reap guard may act on it, and EVERYTHING ELSE is wrapped in
+// ErrWorkerUnroutable so nothing can. There is no third branch, because a third
+// branch is how the eight collapses on this branch happened: each was a place
+// that decided for itself which errors were evidence.
+//
+// The caller's spent budget is checked FIRST, and that ordering is the same one
+// cluster.WorkerDialer.handshake takes for the same reason. A worker's refusal
+// that arrives in the instant the deadline expires would otherwise be reported
+// as the worker's non-transient verdict and reap a row, and nothing orders the
+// two timers: an expiry is never evidence about a backend, so it is answered as
+// the caller's own timeout rather than as an answer.
+func controlFailure(ctx context.Context, nodeID string, err error) error {
+ if err == nil {
+ return nil
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return fmt.Errorf("control rpc to node %q: %w: %w", nodeID, ErrWorkerUnroutable, ctxErr)
+ }
+ if cluster.IsWorkerAnswer(err) {
+ return err
+ }
+ return fmt.Errorf("control rpc to node %q: %w: %w", nodeID, ErrWorkerUnroutable, err)
+}
diff --git a/core/services/nodes/control_client_test.go b/core/services/nodes/control_client_test.go
new file mode 100644
index 000000000000..d8abc1197a5d
--- /dev/null
+++ b/core/services/nodes/control_client_test.go
@@ -0,0 +1,317 @@
+package nodes
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The whole of this task is the table controlFailure implements: every way a
+// control RPC can fail lands in exactly one of four conditions, and none of
+// them may be reported as another. The rows, and what each permits:
+//
+// - the WORKER'S OWN ANSWER, which a reap guard may act on;
+// - an UNREACHABLE PEER or a lost route, which nobody may act on;
+// - a SPENT BUDGET, which nobody may act on;
+// - the worker answering that it is OLDER than this frontend, which only the
+// legacy upgrade fallback may act on.
+var _ = Describe("ControlClient", func() {
+ Context("error mapping", func() {
+ dialerReturning := func(err error) WorkerNetDialerFor {
+ return func(string) func(context.Context, string, string) (net.Conn, error) {
+ return func(context.Context, string, string) (net.Conn, error) { return nil, err }
+ }
+ }
+
+ It("keeps a worker's own refusal matchable and does NOT wrap it as unroutable", func() {
+ c := NewControlClient(dialerReturning(
+ fmt.Errorf("%w: no such process", cluster.ErrStreamTargetUnavailable)), "tok")
+ err := c.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})
+ Expect(cluster.IsWorkerAnswer(err)).To(BeTrue())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeFalse())
+ })
+
+ DescribeTable("reports every non-answer as unroutable and never as a worker verdict",
+ func(cause error) {
+ c := NewControlClient(dialerReturning(cause), "tok")
+ err := c.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ },
+ Entry("no route", fmt.Errorf("reaching node: %w", cluster.ErrNoRoute)),
+ Entry("no connection recorded", cluster.ErrNoConnection),
+ Entry("peer unreachable", cluster.ErrPeerUnreachable),
+ Entry("no relay path", cluster.ErrNoRelayPath),
+ // The fourth refusal code. The worker DID send it, and it still
+ // must not be a verdict: it is what a worker says when it learned
+ // nothing, and those clear on their own.
+ Entry("the worker learned nothing", fmt.Errorf("%w: late frame", cluster.ErrStreamNotServed)),
+ Entry("a reply code this frontend does not know", errors.New(`tunnel stream refused with unrecognised code "from-the-future": x`)),
+ )
+
+ It("refuses without a dialer rather than reaching for an address", func() {
+ c := NewControlClient(nil, "tok")
+ err := c.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ Expect(err).To(MatchError(ErrWorkerUnroutable))
+ })
+
+ It("refuses when the dialer has nothing for that node", func() {
+ c := NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
+ return nil
+ }, "tok")
+ err := c.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("reports a spent budget as unroutable, so nothing acts on an expiry", func() {
+ expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
+ defer cancel()
+
+ c := NewControlClient(dialerReturning(errors.New("never reached")), "tok")
+ err := c.Call(expired, "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})
+
+ Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+
+ // A timeout is not a verdict, and this is the ordering that enforces
+ // it. Phase 2's final defect was a worker refusal that arrived in the
+ // instant the budget expired and was reported as the worker's
+ // non-transient answer, which reaps a row.
+ //
+ // Asserted on the mapping function directly, and deliberately so.
+ // Nothing orders the two timers, so a spec that drove this through Call
+ // could only ever produce one of the two orderings by luck: with a
+ // context already spent, the HTTP client returns before the dialler is
+ // even asked, and the refusal this is about never happens. Reaching for
+ // the collapse through the public call was tried and left the suite
+ // green under the mutation that removes the guard.
+ DescribeTable("decides between a worker's refusal and a spent budget by the BUDGET first",
+ func(spent bool, wantAnswer bool) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if spent {
+ var stop context.CancelFunc
+ ctx, stop = context.WithDeadline(ctx, time.Now().Add(-time.Second))
+ defer stop()
+ }
+ refusal := fmt.Errorf("%w: no such process", cluster.ErrStreamTargetUnavailable)
+ err := controlFailure(ctx, "n1", refusal)
+
+ Expect(cluster.IsWorkerAnswer(err)).To(Equal(wantAnswer))
+ Expect(errors.Is(err, context.DeadlineExceeded)).To(Equal(spent))
+ },
+ // The negative control: with budget left, the very same refusal is
+ // the worker speaking and a reap guard may act on it. Without it
+ // this table would pass on a mapping that never reports an answer.
+ Entry("budget left: the worker spoke", false, true),
+ Entry("budget spent: the caller's own timeout", true, false),
+ )
+ })
+
+ Context("against a worker's HTTP answers", func() {
+ var (
+ srv *httptest.Server
+ handler http.HandlerFunc
+ client *ControlClient
+ )
+
+ BeforeEach(func() {
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler(w, r)
+ }))
+ DeferCleanup(srv.Close)
+ addr := srv.Listener.Addr().String()
+ client = NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", addr)
+ }
+ }, "tok")
+ })
+
+ It("addresses the node in the URL host and carries the bearer token", func() {
+ var gotHost, gotAuth, gotPath, gotMethod string
+ handler = func(w http.ResponseWriter, r *http.Request) {
+ gotHost, gotAuth, gotPath, gotMethod = r.Host, r.Header.Get("Authorization"), r.URL.Path, r.Method
+ _, _ = w.Write([]byte(`{}`))
+ }
+ Expect(client.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &struct{}{})).To(Succeed())
+
+ Expect(gotHost).To(Equal("n1" + unroutableHostSuffix))
+ Expect(gotAuth).To(Equal("Bearer tok"))
+ Expect(gotPath).To(Equal(workerctl.PathModelsRunning))
+ // POST, because a control verb is a command: the worker refuses a
+ // GET so a probe cannot fire one, and a client that sent GET would
+ // be refused rather than served.
+ Expect(gotMethod).To(Equal(http.MethodPost))
+ })
+
+ It("hands the worker's own reply back with its Error field intact", func() {
+ // A verb's own failure is a 200 with Error set. The CALLER reads
+ // it; this is not an error here, and reporting it as one would put
+ // the worker's verdict in the bucket reserved for a broken link.
+ handler = func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"success":false,"error":"disk full"}`))
+ }
+ var reply messaging.BackendDeleteReply
+ Expect(client.Call(context.Background(), "n1", workerctl.PathBackendDelete, struct{}{}, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ Expect(reply.Error).To(Equal("disk full"))
+ })
+
+ It("accepts a 204 for the verbs that answer nothing", func() {
+ handler = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
+ Expect(client.Call(context.Background(), "n1", workerctl.PathNodeStop, struct{}{}, nil)).To(Succeed())
+ })
+
+ It("reports an unknown control verb as unsupported, and not as absence", func() {
+ handler = func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "unknown worker control path "+r.URL.Path, http.StatusNotFound)
+ }
+ err := client.Call(context.Background(), "n1", workerctl.Prefix+"invented", struct{}{}, &struct{}{})
+ Expect(err).To(MatchError(ErrWorkerControlUnsupported))
+ Expect(errors.Is(err, cluster.ErrNoRoute)).To(BeFalse())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+
+ DescribeTable("reports a worker that failed to SERVE the request as unroutable, never as a verdict",
+ func(status int) {
+ handler = func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "boom", status) }
+ err := client.Call(context.Background(), "n1", workerctl.PathBackendList, struct{}{}, &struct{}{})
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ Expect(errors.Is(err, ErrWorkerControlUnsupported)).To(BeFalse())
+ },
+ Entry("the handler failed", http.StatusInternalServerError),
+ Entry("the body could not be read", http.StatusBadRequest),
+ Entry("the method was refused", http.StatusMethodNotAllowed),
+ Entry("a proxy in the path", http.StatusBadGateway),
+ )
+
+ It("reports a reply it cannot decode as unroutable, not as an empty answer", func() {
+ // An empty ModelsRunningReply says "this worker is running nothing",
+ // which the reconciler acts on. It must never be manufactured from
+ // a body that would not parse.
+ handler = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) }
+ var reply messaging.ModelsRunningReply
+ err := client.Call(context.Background(), "n1", workerctl.PathModelsRunning, struct{}{}, &reply)
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+
+ It("streams install progress in order and returns the terminal reply", func() {
+ handler = func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", workerctl.ContentTypeStream)
+ enc := json.NewEncoder(w)
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":50}`)})
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":100}`)})
+ _ = enc.Encode(workerctl.Envelope{Reply: json.RawMessage(`{"success":true}`)})
+ }
+ var seen []float64
+ var reply messaging.BackendInstallReply
+ err := client.CallStreaming(context.Background(), "n1", workerctl.PathBackendInstall,
+ messaging.BackendInstallRequest{Backend: "mock", OpID: "op-1"}, &reply,
+ func(ev messaging.BackendInstallProgressEvent) { seen = append(seen, ev.Percentage) })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(seen).To(Equal([]float64{50, 100}))
+ Expect(reply.Success).To(BeTrue())
+ })
+
+ It("stops reading at the reply line, so nothing after it can be taken for progress", func() {
+ handler = func(w http.ResponseWriter, _ *http.Request) {
+ enc := json.NewEncoder(w)
+ _ = enc.Encode(workerctl.Envelope{Reply: json.RawMessage(`{"success":true}`)})
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":10}`)})
+ }
+ var seen []float64
+ var reply messaging.BackendInstallReply
+ Expect(client.CallStreaming(context.Background(), "n1", workerctl.PathBackendInstall,
+ struct{}{}, &reply,
+ func(ev messaging.BackendInstallProgressEvent) { seen = append(seen, ev.Percentage) })).To(Succeed())
+ Expect(seen).To(BeEmpty())
+ Expect(reply.Success).To(BeTrue())
+ })
+
+ It("reports a stream that ends before its reply line as unroutable, not as a failed install", func() {
+ // A tunnel that dies mid-install must not be read as the worker
+ // saying the install failed. This is the collapse the phase exists
+ // to prevent, one layer up from where phase 2 fixed it.
+ handler = func(w http.ResponseWriter, _ *http.Request) {
+ enc := json.NewEncoder(w)
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":50}`)})
+ hijackAndClose(w)
+ }
+ var reply messaging.BackendInstallReply
+ err := client.CallStreaming(context.Background(), "n1", workerctl.PathBackendInstall,
+ struct{}{}, &reply, nil)
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ Expect(reply.Success).To(BeFalse())
+ })
+
+ It("reports a stream with no lines at all as unroutable", func() {
+ handler = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }
+ var reply messaging.BackendInstallReply
+ err := client.CallStreaming(context.Background(), "n1", workerctl.PathBackendInstall,
+ struct{}{}, &reply, nil)
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(errors.Is(err, io.ErrUnexpectedEOF)).To(BeTrue())
+ })
+
+ It("keeps going past a progress line it cannot read, since progress is transient", func() {
+ handler = func(w http.ResponseWriter, _ *http.Request) {
+ enc := json.NewEncoder(w)
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":"not a number"}`)})
+ _ = enc.Encode(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":70}`)})
+ _ = enc.Encode(workerctl.Envelope{Reply: json.RawMessage(`{"success":true}`)})
+ }
+ var seen []float64
+ var reply messaging.BackendInstallReply
+ Expect(client.CallStreaming(context.Background(), "n1", workerctl.PathBackendInstall,
+ struct{}{}, &reply,
+ func(ev messaging.BackendInstallProgressEvent) { seen = append(seen, ev.Percentage) })).To(Succeed())
+ Expect(seen).To(Equal([]float64{70}))
+ Expect(reply.Success).To(BeTrue())
+ })
+
+ It("reports a streaming 404 as unsupported rather than as a truncated stream", func() {
+ handler = func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "unknown worker control path "+r.URL.Path, http.StatusNotFound)
+ }
+ var reply messaging.BackendUpgradeReply
+ err := client.CallStreaming(context.Background(), "n1", workerctl.PathBackendUpgrade, struct{}{}, &reply, nil)
+ Expect(err).To(MatchError(ErrWorkerControlUnsupported))
+ })
+ })
+})
+
+// hijackAndClose ends a response mid-body without the chunked terminator, which
+// is what a tunnel dying under an in-flight verb looks like to the reader.
+func hijackAndClose(w http.ResponseWriter) {
+ GinkgoHelper()
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ hj, ok := w.(http.Hijacker)
+ Expect(ok).To(BeTrue(), "the test server must support hijacking")
+ conn, buf, err := hj.Hijack()
+ Expect(err).NotTo(HaveOccurred())
+ _ = buf.Flush()
+ _ = conn.Close()
+}
diff --git a/core/services/nodes/control_worker_fake_test.go b/core/services/nodes/control_worker_fake_test.go
new file mode 100644
index 000000000000..b76d59b02496
--- /dev/null
+++ b/core/services/nodes/control_worker_fake_test.go
@@ -0,0 +1,295 @@
+package nodes
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// controlKey names one control verb on one node.
+//
+// It replaces the NATS subject the scripted double used to key on, and it is
+// the same pair the real client addresses: the node picks the tunnel and the
+// path picks the verb.
+func controlKey(nodeID, path string) string { return nodeID + " " + path }
+
+// scriptedControlWorkers is a fleet of fake workers reachable only the way a
+// real one is: through a per-node dialer, over HTTP, on the control paths.
+//
+// One HTTP server serves every node, and which node a request is FOR is read
+// off the Host header, because that is what ControlClient puts there. That is
+// not a convenience: it means a client addressing the wrong node's URL through
+// the right node's tunnel would be visible here, which a per-node server could
+// not see.
+type scriptedControlWorkers struct {
+ mu sync.Mutex
+ srv *httptest.Server
+
+ replies map[string][]byte
+ unsupported map[string]bool
+ matched map[string][]matchedControlReply
+ progress map[string][]messaging.BackendInstallProgressEvent
+
+ // unreachable and expired are keyed by NODE, not by verb, because they are
+ // failures of the ROUTE and a route belongs to a node. They are what the
+ // dialer answers with; see scriptUnroutable and scriptTimeout.
+ unreachable map[string]bool
+ expired map[string]bool
+
+ // hangs names the verbs whose handler never answers, so the only thing
+ // that ends the call is the caller's own budget.
+ hangs map[string]bool
+
+ // serverErrors maps a verb to the body of the 5xx it answers with.
+ serverErrors map[string]string
+
+ // calls records every request that reached a worker, in order.
+ calls []requestCall
+}
+
+// matchedControlReply is a canned reply that fires only for a request matching
+// pred. It exists so a spec can tell "install with Force=true" (the legacy
+// upgrade fallback) from an ordinary install on the same verb.
+type matchedControlReply struct {
+ pred func(messaging.BackendInstallRequest) bool
+ reply []byte
+}
+
+func newScriptedControlWorkers() *scriptedControlWorkers {
+ s := &scriptedControlWorkers{
+ replies: map[string][]byte{},
+ unsupported: map[string]bool{},
+ matched: map[string][]matchedControlReply{},
+ progress: map[string][]messaging.BackendInstallProgressEvent{},
+ unreachable: map[string]bool{},
+ expired: map[string]bool{},
+ hangs: map[string]bool{},
+ serverErrors: map[string]string{},
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc(workerctl.Prefix, s.serve)
+ s.srv = httptest.NewServer(mux)
+ DeferCleanup(s.srv.Close)
+ return s
+}
+
+// dialer hands ControlClient the per-node transport it expects.
+//
+// A node scripted unreachable or expired never reaches the server, which is
+// what a real route failure looks like: nothing is asked of the worker and
+// nothing is learned about it.
+func (s *scriptedControlWorkers) dialer() WorkerNetDialerFor {
+ return func(nodeID string) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, _, _ string) (net.Conn, error) {
+ s.mu.Lock()
+ unreachable, expired := s.unreachable[nodeID], s.expired[nodeID]
+ s.mu.Unlock()
+ switch {
+ case expired:
+ return nil, context.DeadlineExceeded
+ case unreachable:
+ return nil, fmt.Errorf("reaching node %q: %w", nodeID, cluster.ErrNoRoute)
+ }
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", s.srv.Listener.Addr().String())
+ }
+ }
+}
+
+func (s *scriptedControlWorkers) controlClient() *ControlClient {
+ return NewControlClient(s.dialer(), "test-token")
+}
+
+// nodeOf recovers the node a request was addressed to from its Host header.
+func nodeOf(host string) string {
+ return strings.TrimSuffix(host, unroutableHostSuffix)
+}
+
+func (s *scriptedControlWorkers) serve(w http.ResponseWriter, r *http.Request) {
+ body, readErr := io.ReadAll(r.Body)
+ Expect(readErr).ToNot(HaveOccurred())
+ key := controlKey(nodeOf(r.Host), r.URL.Path)
+
+ s.mu.Lock()
+ s.calls = append(s.calls, requestCall{Subject: key, Data: body})
+ unsupported := s.unsupported[key]
+ serverError, failing := s.serverErrors[key]
+ hang := s.hangs[key]
+ reply := s.replies[key]
+ matchers := s.matched[key]
+ ticks := s.progress[key]
+ s.mu.Unlock()
+
+ if hang {
+ // The worker took the request and never answered. Only the caller's
+ // own budget ends this, which is what makes the budget observable.
+ <-r.Context().Done()
+ return
+ }
+ if unsupported {
+ http.Error(w, "unknown worker control path "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ if failing {
+ http.Error(w, serverError, http.StatusInternalServerError)
+ return
+ }
+ if len(matchers) > 0 {
+ var req messaging.BackendInstallRequest
+ _ = json.Unmarshal(body, &req)
+ for _, m := range matchers {
+ if m.pred(req) {
+ reply = m.reply
+ break
+ }
+ }
+ }
+ if reply == nil {
+ // A verb no spec scripted. Answered LOUDLY rather than plausibly: a
+ // forgotten script must be a red spec, never a worker that looks absent.
+ http.Error(w, "this spec scripted no answer for "+key, http.StatusInternalServerError)
+ return
+ }
+
+ if r.URL.Path != workerctl.PathBackendInstall && r.URL.Path != workerctl.PathBackendUpgrade {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(reply)
+ return
+ }
+
+ // The two streaming verbs: zero or more progress lines, then exactly one
+ // reply line, always last.
+ w.Header().Set("Content-Type", workerctl.ContentTypeStream)
+ w.WriteHeader(http.StatusOK)
+ enc := json.NewEncoder(w)
+ for _, ev := range ticks {
+ raw, err := json.Marshal(ev)
+ if err != nil {
+ continue
+ }
+ _ = enc.Encode(workerctl.Envelope{Progress: raw})
+ }
+ _ = enc.Encode(workerctl.Envelope{Reply: reply})
+}
+
+func (s *scriptedControlWorkers) scriptReply(key string, reply any) {
+ raw, err := json.Marshal(reply)
+ Expect(err).ToNot(HaveOccurred())
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.replies[key] = raw
+}
+
+// scriptRawReply scripts a reply byte for byte, so a spec can express what a
+// worker on a DIFFERENT build would send rather than what today's struct
+// marshals to.
+func (s *scriptedControlWorkers) scriptRawReply(key string, raw []byte) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.replies[key] = raw
+}
+
+// scriptUnsupported makes the worker answer 404 for one verb, which is what a
+// build older than this frontend does.
+func (s *scriptedControlWorkers) scriptUnsupported(key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.unsupported[key] = true
+}
+
+// scriptServerError makes one verb answer a 5xx carrying body, which is the
+// worker failing to SERVE the request rather than answering it.
+//
+// It exists so a spec can put chosen text inside the ERROR a control RPC
+// produces. A reply whose Error field carries that text is a different thing
+// entirely: it comes back with a nil error, so it never reaches the code that
+// classifies failures, and a spec built on one cannot see a classifier widen.
+func (s *scriptedControlWorkers) scriptServerError(key, body string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.serverErrors[key] = body
+}
+
+// scriptUnroutable makes every route to a node fail without reaching it. The
+// worker is asked nothing, so nothing is learned about it.
+func (s *scriptedControlWorkers) scriptUnroutable(nodeID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.unreachable[nodeID] = true
+}
+
+// scriptTimeout makes a node's RPCs end with the caller's budget spent.
+//
+// Injected at the dial rather than by making a handler sleep, and the
+// difference is only in how long the spec takes: a real worker that answers too
+// late ends the same way, with the caller's own deadline, because
+// cluster.WorkerDialer reports a handshake that outlives the budget as exactly
+// this. Answering it instantly keeps the adapter's real install timeout, which
+// the retry-scheduling assertions read.
+func (s *scriptedControlWorkers) scriptTimeout(nodeID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.expired[nodeID] = true
+}
+
+// clearTimeout lets a node answer again, as a worker that finished a long
+// install in the background does.
+func (s *scriptedControlWorkers) clearTimeout(nodeID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.expired, nodeID)
+}
+
+func (s *scriptedControlWorkers) scriptReplyMatching(key string, pred func(messaging.BackendInstallRequest) bool, reply messaging.BackendInstallReply) {
+ raw, err := json.Marshal(reply)
+ Expect(err).ToNot(HaveOccurred())
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.matched[key] = append(s.matched[key], matchedControlReply{pred: pred, reply: raw})
+}
+
+// scriptProgress queues the progress lines a streaming verb writes ahead of its
+// reply. There is no window to miss them in and nothing to subscribe to: they
+// are part of the response the caller is already reading.
+func (s *scriptedControlWorkers) scriptProgress(key string, events []messaging.BackendInstallProgressEvent) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.progress[key] = events
+}
+
+// scriptHang makes one verb on one node accept the request and never answer,
+// so the call ends only when the caller's budget does.
+//
+// It is how a spec observes which budget a verb was given. HTTP carries no
+// deadline and the transport dials on a context of its own, so the budget is
+// invisible from the far side; how long the client is willing to wait is the
+// only thing that shows it.
+func (s *scriptedControlWorkers) scriptHang(key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.hangs[key] = true
+}
+
+// callSubjects reports the (node, verb) pairs that reached a worker, in order.
+func (s *scriptedControlWorkers) callSubjects() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]string, 0, len(s.calls))
+ for _, c := range s.calls {
+ out = append(out, c.Subject)
+ }
+ return out
+}
diff --git a/core/services/nodes/disk_headroom_test.go b/core/services/nodes/disk_headroom_test.go
index f14abd8292d2..536add2f3773 100644
--- a/core/services/nodes/disk_headroom_test.go
+++ b/core/services/nodes/disk_headroom_test.go
@@ -145,7 +145,7 @@ var _ = Describe("scheduling a model onto a cluster without disk headroom", func
reg.findIdleNode = &BackendNode{ID: "n1", Name: "nvidia-thor", Address: "10.0.0.1:50051"}
backend = &holdBackend{}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
router = NewSmartRouter(reg, SmartRouterOptions{
Unloader: unloader,
diff --git a/core/services/nodes/distributed_store.go b/core/services/nodes/distributed_store.go
index ba1379367413..5e1a359035fa 100644
--- a/core/services/nodes/distributed_store.go
+++ b/core/services/nodes/distributed_store.go
@@ -2,7 +2,9 @@ package nodes
import (
"context"
+ "fmt"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
@@ -14,10 +16,26 @@ import (
type DistributedModelStore struct {
local model.ModelStore
registry ModelLookup
+ // clients builds the gRPC client for a model that lives on a worker.
+ //
+ // It is not optional in a real deployment, and the reason is the second
+ // construction path this store used to be: a *model.Model built with a nil
+ // client makes pkg/model.Model.GRPC dial its raw address with gRPC's own
+ // dialer the first time anything touches it, which is exactly the direct
+ // dial to a worker's advertised address the tunnel replaces. That path is
+ // reached in production, by ShutdownModel's Free and by the backend
+ // monitor's Status, so it is not theoretical.
+ clients BackendClientFactory
}
-func NewDistributedModelStore(local model.ModelStore, registry ModelLookup) *DistributedModelStore {
- return &DistributedModelStore{local: local, registry: registry}
+// NewDistributedModelStore returns the store, which reaches a remote model's
+// backend through clients.
+//
+// A nil clients is a programming error and is treated as one: Range refuses to
+// synthesise a model it cannot give a working client to, rather than handing
+// back one that silently dials the worker's address. See the field comment.
+func NewDistributedModelStore(local model.ModelStore, registry ModelLookup, clients BackendClientFactory) *DistributedModelStore {
+ return &DistributedModelStore{local: local, registry: registry, clients: clients}
}
// Get checks the local cache only. In distributed mode, models must be routed
@@ -73,16 +91,44 @@ func (s *DistributedModelStore) Range(fn func(string, *model.Model) bool) {
}
seen[nm.ModelName] = true
- // Look up the node address
- node, err := s.registry.Get(ctx, nm.NodeID)
- if err != nil {
- xlog.Warn("DistributedModelStore: failed to get node for model", "model", nm.ModelName, "nodeID", nm.NodeID, "error", err)
+ // The REPLICA's address, not the node's. This used to name the node,
+ // which was the worker's base gRPC port and never the port the backend
+ // process actually listens on, so Free and Status on a model reached
+ // from here went to the wrong place; with workers no longer advertising
+ // anything it would name nothing at all.
+ if nm.WorkerLocalAddress == "" {
+ xlog.Warn("DistributedModelStore: not listing a replica whose backend process is unnamed",
+ "model", nm.ModelName, "nodeID", nm.NodeID, "replica", nm.ReplicaIndex)
continue
}
- m := model.NewModel(nm.ModelName, node.Address, nil)
+ // NewModelWithClient, never NewModel: a model built without a client
+ // lazily dials its address with gRPC's default dialer the first time
+ // anything calls GRPC() on it, which reaches a worker only while
+ // workers still listen on a routable address. Building the client here
+ // means the bypass has no path left rather than an unused one.
+ client, err := s.clientFor(nm.NodeID, nm.WorkerLocalAddress)
+ if err != nil {
+ xlog.Error("DistributedModelStore: not listing a remote model it cannot reach",
+ "model", nm.ModelName, "nodeID", nm.NodeID, "error", err)
+ continue
+ }
+ m := model.NewModelWithClient(nm.ModelName, nm.WorkerLocalAddress, client)
if !fn(nm.ModelName, m) {
return
}
}
}
+
+// clientFor builds the backend client for a model running on a worker.
+//
+// It fails rather than falling back. A fallback here would be invisible: the
+// listing would look complete, shutdown would appear to work, and the direct
+// dial underneath it would succeed on a single-host developer setup and fail
+// against every worker that has no inbound port.
+func (s *DistributedModelStore) clientFor(nodeID, address string) (grpc.Backend, error) {
+ if s.clients == nil {
+ return nil, fmt.Errorf("no backend client factory is wired into the distributed model store: %w", ErrNoWorkerDialer)
+ }
+ return s.clients.NewClientForNode(nodeID, address, false)
+}
diff --git a/core/services/nodes/distributed_store_test.go b/core/services/nodes/distributed_store_test.go
index 9b6e4ccc9464..92ed6d0a5b4b 100644
--- a/core/services/nodes/distributed_store_test.go
+++ b/core/services/nodes/distributed_store_test.go
@@ -2,11 +2,13 @@ package nodes
import (
"context"
+ "errors"
"fmt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
)
@@ -48,15 +50,17 @@ var _ ModelLookup = (*fakeModelLookup)(nil)
var _ = Describe("DistributedModelStore", func() {
var (
- local *model.InMemoryModelStore
- lookup *fakeModelLookup
- store *DistributedModelStore
+ local *model.InMemoryModelStore
+ lookup *fakeModelLookup
+ clients *fakeBackendClientFactory
+ store *DistributedModelStore
)
BeforeEach(func() {
local = model.NewInMemoryModelStore()
lookup = newFakeModelLookup()
- store = NewDistributedModelStore(local, lookup)
+ clients = newFakeBackendClientFactory()
+ store = NewDistributedModelStore(local, lookup, clients)
})
Describe("Get", func() {
@@ -95,11 +99,11 @@ var _ = Describe("DistributedModelStore", func() {
local.Set("model-a", localModel)
// DB model (not in local)
- dbNode := &BackendNode{ID: "node-2", Address: "10.0.0.3:50051"}
+ dbNode := &BackendNode{ID: "node-2"}
lookup.nodes["node-2"] = dbNode
lookup.allModels = []NodeModel{
- {NodeID: "node-2", ModelName: "model-b"},
- {NodeID: "node-2", ModelName: "model-a"}, // duplicate — should be skipped
+ {NodeID: "node-2", ModelName: "model-b", WorkerLocalAddress: "127.0.0.1:50052"},
+ {NodeID: "node-2", ModelName: "model-a", WorkerLocalAddress: "127.0.0.1:50053"}, // duplicate, should be skipped
}
visited := make(map[string]bool)
@@ -113,6 +117,96 @@ var _ = Describe("DistributedModelStore", func() {
Expect(visited).To(HaveLen(2))
})
+ It("gives every remote model a client that reaches the worker through its node", func() {
+ // The second construction path, closed. A model built with a nil
+ // client makes pkg/model.Model.GRPC dial its raw address with
+ // gRPC's own dialer the first time anything touches it, which
+ // bypasses the worker's tunnel completely. It is reached in
+ // production: ShutdownModel calls Free on it and the backend
+ // monitor calls Status.
+ dbNode := &BackendNode{ID: "node-2"}
+ lookup.nodes["node-2"] = dbNode
+ lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
+
+ var got *model.Model
+ store.Range(func(id string, m *model.Model) bool {
+ if id == "remote-model" {
+ got = m
+ }
+ return true
+ })
+ Expect(got).ToNot(BeNil())
+ // The client is the factory's, so GRPC() returns it rather than
+ // building one by dialling. Asked for by NODE, not by address.
+ Expect(got.GRPC(false, nil)).To(BeIdenticalTo(grpc.Backend(clients.defaultClient)))
+ Expect(clients.nodesSeen()).To(ContainElement("node-2"))
+ })
+
+ It("names the replica's own backend process, not the node", func() {
+ // This used to pass the NODE's address, which was the worker's base
+ // gRPC port and never the port a backend process listens on, so
+ // Free and Status on a model listed here went to the wrong process.
+ // A node has no address at all now, so the same code would name the
+ // empty string and the worker would refuse the stream as invalid, a
+ // refusal that reads as the backend answering about itself.
+ lookup.nodes["node-2"] = &BackendNode{ID: "node-2"}
+ lookup.allModels = []NodeModel{{
+ NodeID: "node-2", ModelName: "remote-model", ReplicaIndex: 1,
+ WorkerLocalAddress: "127.0.0.1:50057",
+ }}
+
+ store.Range(func(string, *model.Model) bool { return true })
+ Expect(clients.addressesSeen()).To(ConsistOf("127.0.0.1:50057"))
+ })
+
+ It("skips a replica row that names no backend process", func() {
+ // Nothing can be routed to it, and handing back a model whose
+ // client targets an empty address turns every Free and Status on it
+ // into an invalid-stream refusal from the worker.
+ lookup.nodes["node-2"] = &BackendNode{ID: "node-2"}
+ lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "unnamed-model"}}
+
+ visited := map[string]bool{}
+ store.Range(func(id string, _ *model.Model) bool {
+ visited[id] = true
+ return true
+ })
+ Expect(visited).ToNot(HaveKey("unnamed-model"))
+ Expect(clients.addressesSeen()).To(BeEmpty())
+ })
+
+ It("refuses to list a remote model it has no way to reach", func() {
+ // Loudly, not by falling back. A model handed back here with a
+ // direct-dialling client works on a single-host developer setup and
+ // fails against every worker with no inbound port, which is the
+ // worst way for this defect to behave.
+ clients.refuseForNode = errors.New("no tunnel for you")
+ dbNode := &BackendNode{ID: "node-2"}
+ lookup.nodes["node-2"] = dbNode
+ lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
+
+ visited := map[string]bool{}
+ store.Range(func(id string, _ *model.Model) bool {
+ visited[id] = true
+ return true
+ })
+ Expect(visited).ToNot(HaveKey("remote-model"))
+ })
+
+ It("refuses when no client factory was wired at all", func() {
+ bare := NewDistributedModelStore(local, lookup, nil)
+ dbNode := &BackendNode{ID: "node-2"}
+ lookup.nodes["node-2"] = dbNode
+ lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
+
+ visited := map[string]bool{}
+ bare.Range(func(id string, _ *model.Model) bool {
+ visited[id] = true
+ return true
+ })
+ Expect(visited).ToNot(HaveKey("remote-model"))
+ })
+
It("handles DB list error gracefully", func() {
localModel := model.NewModel("model-x", "10.0.0.1:50051", nil)
local.Set("model-x", localModel)
diff --git a/core/services/nodes/file_stager.go b/core/services/nodes/file_stager.go
index c5ee38556eb4..0c4556ce0db5 100644
--- a/core/services/nodes/file_stager.go
+++ b/core/services/nodes/file_stager.go
@@ -5,11 +5,14 @@ import "context"
// FileStager abstracts file transfer between frontend and backend nodes
// in distributed mode. Two implementations exist:
//
-// 1. S3NATSFileStager (primary): Both sides have FileManager with same S3.
-// Frontend uploads to S3, sends NATS request-reply to backend to download locally.
+// 1. S3FileStager (primary): Both sides have FileManager with same S3.
+// Frontend uploads to S3, then calls the worker's control plane over its
+// tunnel to have it download locally.
//
// 2. HTTPFileStager (fallback): Frontend pushes/pulls files directly over
// HTTP to a small file transfer server on the backend node (no S3 needed).
+//
+// Both reach the worker through its tunnel and neither uses NATS.
type FileStager interface {
// EnsureRemote ensures a local file is available on the remote node.
// Returns the remote-local path.
diff --git a/core/services/nodes/file_stager_http.go b/core/services/nodes/file_stager_http.go
index 79047aad6612..825560c82df4 100644
--- a/core/services/nodes/file_stager_http.go
+++ b/core/services/nodes/file_stager_http.go
@@ -28,9 +28,30 @@ import (
// Files are transferred between the frontend and backend nodes over a small
// HTTP server running alongside the gRPC backend process.
type HTTPFileStager struct {
- httpAddrFor func(nodeID string) (string, error)
- token string
- client *http.Client
+ httpAddrFor func(nodeID string) (string, error)
+ token string
+ // dialFor supplies the transport for one worker. It is per node because a
+ // worker is reached over ITS OWN tunnel, and an http.Transport carries one
+ // DialContext: one shared transport could only ever reach one worker.
+ //
+ // nil means no tunnel dialer is wired, and every request is then refused
+ // rather than sent to the worker's advertised address; see
+ // ErrNoWorkerDialer for why that is not a fallback.
+ dialFor WorkerNetDialerFor
+ // clients caches one *http.Client per node. Caching is what keeps the
+ // connection pool: a client built per request would open a fresh tunnel
+ // stream for every chunk of a multi-gigabyte upload.
+ //
+ // Entries are never pruned, and that is judged acceptable rather than
+ // overlooked. The map is bounded by the number of distinct workers this
+ // frontend has ever staged to, which is bounded by the fleet; each entry is
+ // a transport whose idle connections the 90s IdleConnTimeout above reclaims,
+ // so a departed worker's entry holds a map slot and nothing else. It is the
+ // same shape as PeerPool.links and would need the same thing to fix
+ // properly: a signal that a node has left, which the deregistration path
+ // does not publish today.
+ clientsMu sync.Mutex
+ clients map[string]*http.Client
responseTimeout time.Duration // timeout waiting for server response after upload
maxRetries int // number of retry attempts for transient failures
}
@@ -38,7 +59,8 @@ type HTTPFileStager struct {
// NewHTTPFileStager creates a new HTTP file stager.
// httpAddrFor should return the HTTP address (host:port) for the given node ID.
// token is the registration token used for authentication.
-func NewHTTPFileStager(httpAddrFor func(nodeID string) (string, error), token string) *HTTPFileStager {
+// dialFor supplies the per-node transport; see the dialFor field.
+func NewHTTPFileStager(httpAddrFor func(nodeID string) (string, error), token string, dialFor WorkerNetDialerFor) *HTTPFileStager {
responseTimeout := 30 * time.Minute
if v := os.Getenv("LOCALAI_FILE_TRANSFER_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
@@ -53,11 +75,49 @@ func NewHTTPFileStager(httpAddrFor func(nodeID string) (string, error), token st
}
}
+ return &HTTPFileStager{
+ httpAddrFor: httpAddrFor,
+ token: token,
+ dialFor: dialFor,
+ clients: map[string]*http.Client{},
+ responseTimeout: responseTimeout,
+ maxRetries: maxRetries,
+ }
+}
+
+// clientFor returns the HTTP client that reaches one worker, building it on
+// first use.
+//
+// Every setting below is carried over unchanged from the single shared client
+// this replaced, except DialContext, which now opens a stream on that worker's
+// tunnel instead of connecting to its advertised address. HTTP/2 stays off for
+// the reason it always was: its flow control stalls large uploads.
+//
+// What the tunnel dial does NOT carry over is the net.Dialer's own 30s connect
+// timeout and 15s keepalive, because neither has anything left to act on: there
+// is no TCP connect to time out, and liveness on the link is the yamux
+// session's keepalive rather than the socket's. What still bounds a request is
+// the context the caller passes.
+//
+// No client.Timeout is set, and that is deliberate: for large uploads
+// http.Client.Timeout covers the whole request including the body, and firing
+// mid-write closes the connection and shows up server-side as "connection reset
+// by peer". The upload loop's own resume budget bounds the transfer instead.
+func (h *HTTPFileStager) clientFor(nodeID string) (*http.Client, error) {
+ if h.dialFor == nil {
+ return nil, fmt.Errorf("staging files to node %s: %w", nodeID, ErrNoWorkerDialer)
+ }
+ h.clientsMu.Lock()
+ defer h.clientsMu.Unlock()
+ if c, ok := h.clients[nodeID]; ok {
+ return c, nil
+ }
+ dial := h.dialFor(nodeID)
+ if dial == nil {
+ return nil, fmt.Errorf("staging files to node %s: %w", nodeID, ErrNoWorkerDialer)
+ }
transport := &http.Transport{
- DialContext: (&net.Dialer{
- Timeout: 30 * time.Second,
- KeepAlive: 15 * time.Second, // aggressive keepalive for LAN transfers
- }).DialContext,
+ DialContext: dial,
ForceAttemptHTTP2: false, // HTTP/2 flow control can stall large uploads
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
@@ -66,19 +126,9 @@ func NewHTTPFileStager(httpAddrFor func(nodeID string) (string, error), token st
WriteBufferSize: 256 << 10, // 256 KB
ReadBufferSize: 256 << 10, // 256 KB
}
-
- return &HTTPFileStager{
- httpAddrFor: httpAddrFor,
- token: token,
- // No Timeout set — for large uploads, http.Client.Timeout covers the
- // entire request lifecycle including the body upload. If it fires
- // mid-write, Go closes the connection causing "connection reset by peer"
- // on the server. Instead we use ResponseHeaderTimeout on the transport
- // to cover only the wait-for-server-response phase.
- client: httpclient.New(httpclient.WithTransport(transport)),
- responseTimeout: responseTimeout,
- maxRetries: maxRetries,
- }
+ c := httpclient.New(httpclient.WithTransport(transport))
+ h.clients[nodeID] = c
+ return c, nil
}
func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, key string) (string, error) {
@@ -88,9 +138,13 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
if err != nil {
return "", fmt.Errorf("resolving HTTP address for node %s: %w", nodeID, err)
}
+ client, err := h.clientFor(nodeID)
+ if err != nil {
+ return "", err
+ }
// Probe: check if the remote already has the file with matching content hash.
- if remotePath, ok := h.probeExisting(ctx, addr, localPath, key); ok {
+ if remotePath, ok := h.probeExisting(ctx, client, addr, localPath, key); ok {
xlog.Info("Upload skipped (file already exists with matching hash)", "node", nodeID, "key", key, "remotePath", remotePath)
return remotePath, nil
}
@@ -148,9 +202,9 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
// matching ours unlocks resume from the reported size; any other
// outcome (missing file, hash mismatch, partial-of-different-file)
// resets to 0 and uploads the entire file.
- startOffset := h.resumeOffset(resumeCtx, addr, key, localHash, fileSize)
+ startOffset := h.resumeOffset(resumeCtx, client, addr, key, localHash, fileSize)
- result, err := h.doUpload(ctx, resumeCtx, addr, nodeID, localPath, key, url, fileSize, startOffset, localHash)
+ result, err := h.doUpload(ctx, resumeCtx, client, addr, nodeID, localPath, key, url, fileSize, startOffset, localHash)
if err == nil {
if attempt > 1 {
xlog.Info("File upload succeeded after retry", "node", nodeID, "file", filepath.Base(localPath), "attempt", attempt)
@@ -237,7 +291,7 @@ func nextBackoff(attempt int) time.Duration {
// different target hash). It returns the server-reported size when the
// server's X-Target-SHA256 matches our expected final hash AND the size is
// strictly less than the local file size.
-func (h *HTTPFileStager) resumeOffset(ctx context.Context, addr, key, localHash string, fileSize int64) int64 {
+func (h *HTTPFileStager) resumeOffset(ctx context.Context, client *http.Client, addr, key, localHash string, fileSize int64) int64 {
if localHash == "" || fileSize <= 0 {
return 0
}
@@ -249,7 +303,7 @@ func (h *HTTPFileStager) resumeOffset(ctx context.Context, addr, key, localHash
if h.token != "" {
req.Header.Set("Authorization", "Bearer "+h.token)
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return 0
}
@@ -282,7 +336,7 @@ func (h *HTTPFileStager) resumeOffset(ctx context.Context, addr, key, localHash
// the bytes from startOffset to fileSize-1. The outerCtx is the long-lived
// resume budget; reqCtx is what's bound to the request (currently the same as
// the parent ctx, since http.Client doesn't expose a per-request timeout).
-func (h *HTTPFileStager) doUpload(ctx, outerCtx context.Context, addr, nodeID, localPath, key, url string, fileSize, startOffset int64, expectedHash string) (string, error) {
+func (h *HTTPFileStager) doUpload(ctx, outerCtx context.Context, client *http.Client, addr, nodeID, localPath, key, url string, fileSize, startOffset int64, expectedHash string) (string, error) {
if startOffset < 0 || startOffset > fileSize {
startOffset = 0
}
@@ -337,7 +391,7 @@ func (h *HTTPFileStager) doUpload(ctx, outerCtx context.Context, addr, nodeID, l
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, fileSize-1, fileSize))
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
xlog.Error("File upload failed", "node", nodeID, "file", filepath.Base(localPath),
"size", humanFileSize(fileSize), "offset", startOffset, "error", err)
@@ -441,7 +495,7 @@ func isTransientError(err error) bool {
// file with a matching SHA-256 hash. Returns the remote path and true if the
// upload can be skipped. Any errors (including 405 from older servers) silently
// fall through so the caller proceeds with a normal PUT.
-func (h *HTTPFileStager) probeExisting(ctx context.Context, addr, localPath, key string) (string, bool) {
+func (h *HTTPFileStager) probeExisting(ctx context.Context, client *http.Client, addr, localPath, key string) (string, bool) {
url := fmt.Sprintf("http://%s/v1/files/%s", addr, key)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
@@ -452,7 +506,7 @@ func (h *HTTPFileStager) probeExisting(ctx context.Context, addr, localPath, key
req.Header.Set("Authorization", "Bearer "+h.token)
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return "", false
}
@@ -664,6 +718,10 @@ func (h *HTTPFileStager) FetchRemoteByKey(ctx context.Context, nodeID, key, loca
if err != nil {
return fmt.Errorf("resolving HTTP address for node %s: %w", nodeID, err)
}
+ client, err := h.clientFor(nodeID)
+ if err != nil {
+ return err
+ }
if err := os.MkdirAll(filepath.Dir(localDst), 0750); err != nil {
return fmt.Errorf("creating directory for %s: %w", localDst, err)
@@ -680,7 +738,7 @@ func (h *HTTPFileStager) FetchRemoteByKey(ctx context.Context, nodeID, key, loca
req.Header.Set("Authorization", "Bearer "+h.token)
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("downloading from node %s: %w", nodeID, err)
}
@@ -726,6 +784,10 @@ func (h *HTTPFileStager) AllocRemoteTemp(ctx context.Context, nodeID string) (st
if err != nil {
return "", fmt.Errorf("resolving HTTP address for node %s: %w", nodeID, err)
}
+ client, err := h.clientFor(nodeID)
+ if err != nil {
+ return "", err
+ }
url := fmt.Sprintf("http://%s/v1/files/temp", addr)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
@@ -736,7 +798,7 @@ func (h *HTTPFileStager) AllocRemoteTemp(ctx context.Context, nodeID string) (st
req.Header.Set("Authorization", "Bearer "+h.token)
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("allocating temp file on node %s: %w", nodeID, err)
}
@@ -767,6 +829,10 @@ func (h *HTTPFileStager) ListRemoteDir(ctx context.Context, nodeID, keyPrefix st
if err != nil {
return nil, fmt.Errorf("resolving HTTP address for node %s: %w", nodeID, err)
}
+ client, err := h.clientFor(nodeID)
+ if err != nil {
+ return nil, err
+ }
url := fmt.Sprintf("http://%s/v1/files-list/%s", addr, keyPrefix)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -777,7 +843,7 @@ func (h *HTTPFileStager) ListRemoteDir(ctx context.Context, nodeID, keyPrefix st
req.Header.Set("Authorization", "Bearer "+h.token)
}
- resp, err := h.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("listing dir on node %s: %w", nodeID, err)
}
diff --git a/core/services/nodes/file_stager_s3.go b/core/services/nodes/file_stager_s3.go
index 0d3847b7c2c5..ddef12d6f507 100644
--- a/core/services/nodes/file_stager_s3.go
+++ b/core/services/nodes/file_stager_s3.go
@@ -5,29 +5,70 @@ import (
"fmt"
"time"
- "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/xlog"
)
-// S3NATSFileStager implements FileStager using S3 for storage and NATS
-// request-reply for coordination with backend nodes. Both frontend and
-// backend nodes share the same S3 bucket. The flow is:
+// S3FileStager implements FileStager using an object store for the bytes and
+// the worker's tunnelled control plane for coordination. Both frontend and
+// worker share the same bucket. The flow is:
//
-// 1. Frontend uploads file to S3
-// 2. Frontend sends NATS request to nodes.{nodeID}.files.ensure
-// 3. Backend downloads from S3 to local cache, replies with local path
-type S3NATSFileStager struct {
- fm *storage.FileManager
- nats messaging.MessagingClient
+// 1. Frontend uploads the file to the object store
+// 2. Frontend calls POST /v1/control/files/ensure on the worker's tunnel
+// 3. Worker downloads from the store to its local cache and replies with the
+// local path
+type S3FileStager struct {
+ fm *storage.FileManager
+ control *ControlClient
}
-// NewS3NATSFileStager creates a new S3+NATS file stager.
-func NewS3NATSFileStager(fm *storage.FileManager, nats messaging.MessagingClient) *S3NATSFileStager {
- return &S3NATSFileStager{fm: fm, nats: nats}
+// NewS3FileStager creates a file stager that moves bytes through fm and
+// commands workers over control.
+func NewS3FileStager(fm *storage.FileManager, control *ControlClient) *S3FileStager {
+ return &S3FileStager{fm: fm, control: control}
}
-// NATS request/reply message types
+// The two budgets a file-staging RPC gets. They are the ones the NATS
+// request-reply timeouts carried, kept verbatim: a transfer verb waits out a
+// multi-gigabyte copy, and a metadata verb does not.
+//
+// They are CEILINGS on the caller's own budget rather than budgets of their
+// own. Every call below derives its deadline from the caller's context, so a
+// caller that has already given up stops the RPC too; deriving from a fresh
+// background context would keep commanding a worker nobody is listening to and
+// would let a late answer be read as a live one.
+const (
+ fileTransferRPCTimeout = 10 * time.Minute
+ fileMetadataRPCTimeout = 30 * time.Second
+)
+
+// fileRPCBudget is the ceiling one file-staging verb's RPC gets.
+//
+// The mapping lives here rather than at the call sites, and that is the same
+// argument callWorker is written under: five sites each naming their own
+// constant is five chances to name the wrong one, and a stage verb given the
+// metadata ceiling would abandon a multi-gigabyte copy after thirty seconds
+// while the worker went on making it.
+//
+// A path this function does not know gets the SHORTER ceiling. That is the safe
+// direction: a verb wrongly given 30 seconds fails visibly and is retried,
+// while one wrongly given ten minutes parks a caller on a verb that was never
+// meant to be slow.
+func fileRPCBudget(path string) time.Duration {
+ switch path {
+ case workerctl.PathFilesEnsure, workerctl.PathFilesStage:
+ return fileTransferRPCTimeout
+ case workerctl.PathFilesTemp, workerctl.PathFilesListDir:
+ return fileMetadataRPCTimeout
+ default:
+ return fileMetadataRPCTimeout
+ }
+}
+
+// Control request/reply message types. Their JSON is the shape the
+// nodes..files.* subjects carried, so the worker's handler bodies did not
+// have to change when the carrier did.
type fileEnsureRequest struct {
Key string `json:"key"`
@@ -64,10 +105,25 @@ type fileListDirReply struct {
Error string `json:"error,omitempty"`
}
-// EnsureRemote uploads a local file to S3 (if not already there) and sends
-// a NATS request-reply to the backend node to download it locally.
-func (s *S3NATSFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, key string) (string, error) {
- // Upload to S3 if not already present
+// callWorker issues one file-staging RPC under a deadline derived from the
+// caller's context and bounded by the verb's own ceiling.
+//
+// It exists so the derivation is written ONCE. Five call sites each repeating
+// context.WithTimeout is five chances for one of them to start from a
+// background context instead, and that one site would then keep commanding a
+// worker after its caller had gone, with nothing else in the suite any redder
+// for it. The budget comes from the path rather than from an argument for the
+// same reason; see fileRPCBudget.
+func (s *S3FileStager) callWorker(ctx context.Context, nodeID, path string, req, reply any) error {
+ rpcCtx, cancel := context.WithTimeout(ctx, fileRPCBudget(path))
+ defer cancel()
+ return s.control.Call(rpcCtx, nodeID, path, req, reply)
+}
+
+// EnsureRemote uploads a local file to the object store (if not already there)
+// and tells the worker to fetch it.
+func (s *S3FileStager) EnsureRemote(ctx context.Context, nodeID, localPath, key string) (string, error) {
+ // Upload to the store if not already present
exists, _ := s.fm.Exists(ctx, key)
if !exists {
// Wrap with progress reporting if a staging callback is available
@@ -78,14 +134,12 @@ func (s *S3NATSFileStager) EnsureRemote(ctx context.Context, nodeID, localPath,
}
}
if err := s.fm.UploadWithProgress(ctx, key, localPath, progressFn); err != nil {
- return "", fmt.Errorf("uploading %s to S3: %w", localPath, err)
+ return "", fmt.Errorf("uploading %s to the object store: %w", localPath, err)
}
}
- // Send NATS request-reply to backend
- subject := messaging.SubjectNodeFilesEnsure(nodeID)
- reply, err := messaging.RequestJSON[fileEnsureRequest, fileEnsureReply](s.nats, subject, fileEnsureRequest{Key: key}, 10*time.Minute)
- if err != nil {
+ var reply fileEnsureReply
+ if err := s.callWorker(ctx, nodeID, workerctl.PathFilesEnsure, fileEnsureRequest{Key: key}, &reply); err != nil {
return "", err
}
if reply.Error != "" {
@@ -96,36 +150,37 @@ func (s *S3NATSFileStager) EnsureRemote(ctx context.Context, nodeID, localPath,
return reply.LocalPath, nil
}
-// FetchRemote tells the backend to upload a file to S3, then downloads it locally.
-func (s *S3NATSFileStager) FetchRemote(ctx context.Context, nodeID, remotePath, localDst string) error {
- // Tell backend to upload to S3
+// FetchRemote tells the worker to upload a file to the object store, then
+// downloads it locally.
+func (s *S3FileStager) FetchRemote(ctx context.Context, nodeID, remotePath, localDst string) error {
key := storage.EphemeralKey(remotePath, "fetch", "output")
return s.fetchRemoteWithKey(ctx, nodeID, remotePath, key, localDst, true)
}
-// FetchRemoteByKey tells the backend to upload a file (identified by key) to S3,
-// then downloads it locally. The key is used as-is for S3 routing.
-func (s *S3NATSFileStager) FetchRemoteByKey(ctx context.Context, nodeID, key, localDst string) error {
- // For S3 mode, we still need the remote path — derive it from the key.
- // The backend serves the file from its data dir based on the key prefix.
+// FetchRemoteByKey tells the worker to upload a file (identified by key) to the
+// object store, then downloads it locally. The key is used as-is for routing.
+func (s *S3FileStager) FetchRemoteByKey(ctx context.Context, nodeID, key, localDst string) error {
+ // The remote path is derived from the key: the worker serves the file from
+ // its data dir based on the key prefix.
remotePath := "/" + key // e.g. "/data/quantization/{jobID}/model.gguf"
return s.fetchRemoteWithKey(ctx, nodeID, remotePath, key, localDst, true)
}
-func (s *S3NATSFileStager) fetchRemoteWithKey(ctx context.Context, nodeID, remotePath, key, localDst string, cleanup bool) error {
- subject := messaging.SubjectNodeFilesStage(nodeID)
- reply, err := messaging.RequestJSON[fileStageRequest, fileStageReply](s.nats, subject, fileStageRequest{LocalPath: remotePath, Key: key}, 10*time.Minute)
- if err != nil {
+func (s *S3FileStager) fetchRemoteWithKey(ctx context.Context, nodeID, remotePath, key, localDst string, cleanup bool) error {
+ var reply fileStageReply
+ if err := s.callWorker(ctx, nodeID, workerctl.PathFilesStage, fileStageRequest{LocalPath: remotePath, Key: key}, &reply); err != nil {
return err
}
if reply.Error != "" {
return fmt.Errorf("backend stage failed: %s", reply.Error)
}
- // Download from S3 to local cache
+ // Download from the store to the local cache. The CALLER's context bounds
+ // this rather than the RPC's, because it is this frontend's own work and
+ // the RPC it belonged to has already finished.
cachedPath, err := s.fm.Download(ctx, key)
if err != nil {
- return fmt.Errorf("downloading %s from S3: %w", key, err)
+ return fmt.Errorf("downloading %s from the object store: %w", key, err)
}
// Copy from cache to destination
@@ -141,11 +196,10 @@ func (s *S3NATSFileStager) fetchRemoteWithKey(ctx context.Context, nodeID, remot
return nil
}
-// AllocRemoteTemp asks the backend to allocate a temp file via NATS request-reply.
-func (s *S3NATSFileStager) AllocRemoteTemp(ctx context.Context, nodeID string) (string, error) {
- subject := messaging.SubjectNodeFilesTemp(nodeID)
- reply, err := messaging.RequestJSON[fileTempRequest, fileTempReply](s.nats, subject, fileTempRequest{}, 30*time.Second)
- if err != nil {
+// AllocRemoteTemp asks the worker to allocate a temp file.
+func (s *S3FileStager) AllocRemoteTemp(ctx context.Context, nodeID string) (string, error) {
+ var reply fileTempReply
+ if err := s.callWorker(ctx, nodeID, workerctl.PathFilesTemp, fileTempRequest{}, &reply); err != nil {
return "", err
}
if reply.Error != "" {
@@ -155,10 +209,16 @@ func (s *S3NATSFileStager) AllocRemoteTemp(ctx context.Context, nodeID string) (
return reply.LocalPath, nil
}
-func (s *S3NATSFileStager) ListRemoteDir(ctx context.Context, nodeID, keyPrefix string) ([]string, error) {
- subject := messaging.SubjectNodeFilesListDir(nodeID)
- reply, err := messaging.RequestJSON[fileListDirRequest, fileListDirReply](s.nats, subject, fileListDirRequest{KeyPrefix: keyPrefix}, 30*time.Second)
- if err != nil {
+// ListRemoteDir returns the relative paths of every file under keyPrefix on the
+// worker.
+//
+// Nothing truncates the answer, at either end. The bus this used to ride put a
+// ceiling on how big a reply could be, and a wide model directory was the case
+// that pushed against it; a response body has no such ceiling, and a short
+// listing would read to the caller as files the worker does not have.
+func (s *S3FileStager) ListRemoteDir(ctx context.Context, nodeID, keyPrefix string) ([]string, error) {
+ var reply fileListDirReply
+ if err := s.callWorker(ctx, nodeID, workerctl.PathFilesListDir, fileListDirRequest{KeyPrefix: keyPrefix}, &reply); err != nil {
return nil, err
}
if reply.Error != "" {
@@ -168,11 +228,10 @@ func (s *S3NATSFileStager) ListRemoteDir(ctx context.Context, nodeID, keyPrefix
return reply.Files, nil
}
-// StageRemoteToStore tells the backend to upload a local file to S3.
-func (s *S3NATSFileStager) StageRemoteToStore(ctx context.Context, nodeID, remotePath, key string) error {
- subject := messaging.SubjectNodeFilesStage(nodeID)
- reply, err := messaging.RequestJSON[fileStageRequest, fileStageReply](s.nats, subject, fileStageRequest{LocalPath: remotePath, Key: key}, 10*time.Minute)
- if err != nil {
+// StageRemoteToStore tells the worker to upload a local file to shared storage.
+func (s *S3FileStager) StageRemoteToStore(ctx context.Context, nodeID, remotePath, key string) error {
+ var reply fileStageReply
+ if err := s.callWorker(ctx, nodeID, workerctl.PathFilesStage, fileStageRequest{LocalPath: remotePath, Key: key}, &reply); err != nil {
return err
}
if reply.Error != "" {
diff --git a/core/services/nodes/file_stager_s3_test.go b/core/services/nodes/file_stager_s3_test.go
new file mode 100644
index 000000000000..beceeb2facb7
--- /dev/null
+++ b/core/services/nodes/file_stager_s3_test.go
@@ -0,0 +1,194 @@
+package nodes
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The S3 file stager's half of the file-staging contract: which verb each
+// method addresses, and whose budget bounds it.
+//
+// The stager is reached through the real ControlClient over a real HTTP
+// transport onto a scripted worker, because what these specs are about is
+// transport behaviour: a double that never dials anything cannot fail the way
+// a spent budget or a rejected route fails.
+var _ = Describe("the S3 file stager's control RPCs", func() {
+ const nodeID = "stager-node"
+
+ var (
+ workers *scriptedControlWorkers
+ stager *S3FileStager
+ local string
+ )
+
+ BeforeEach(func() {
+ workers = newScriptedControlWorkers()
+
+ dir := GinkgoT().TempDir()
+ store, err := storage.NewFilesystemStore(filepath.Join(dir, "objectstore"))
+ Expect(err).NotTo(HaveOccurred())
+ fm, err := storage.NewFileManager(store, filepath.Join(dir, "cache"))
+ Expect(err).NotTo(HaveOccurred())
+ stager = NewS3FileStager(fm, workers.controlClient())
+
+ local = filepath.Join(dir, "model.gguf")
+ Expect(os.WriteFile(local, []byte("weights"), 0o600)).To(Succeed())
+ })
+
+ // The budgets are written out BY HAND and deliberately not derived from the
+ // constants under test. They are the timeouts the NATS request-reply calls
+ // carried, and keeping them is a compatibility fact rather than a taste:
+ // an operator whose staging of a 35 GB checkpoint fits inside ten minutes
+ // today must not find it cut to thirty seconds by the carrier changing.
+ DescribeTable("gives each verb the ceiling its NATS timeout carried",
+ func(path string, want time.Duration) { Expect(fileRPCBudget(path)).To(Equal(want)) },
+ Entry("ensure moves bytes", workerctl.PathFilesEnsure, 10*time.Minute),
+ Entry("stage moves bytes", workerctl.PathFilesStage, 10*time.Minute),
+ Entry("temp is metadata", workerctl.PathFilesTemp, 30*time.Second),
+ Entry("listdir is metadata", workerctl.PathFilesListDir, 30*time.Second),
+ )
+
+ It("gives a verb it does not know the shorter ceiling", func() {
+ // The safe direction: a verb wrongly given thirty seconds fails visibly
+ // and is retried, while one wrongly given ten minutes parks a caller on
+ // a verb that was never meant to be slow.
+ Expect(fileRPCBudget(workerctl.Prefix + "invented")).To(Equal(30 * time.Second))
+ })
+
+ DescribeTable("addresses the verb that verb's path names",
+ func(path string, reply any, call func(*S3FileStager) error) {
+ workers.scriptReply(controlKey(nodeID, path), reply)
+ Expect(call(stager)).To(Succeed())
+ Expect(workers.callSubjects()).To(ContainElement(controlKey(nodeID, path)))
+ },
+ Entry("ensure", workerctl.PathFilesEnsure, fileEnsureReply{LocalPath: "/w/models/m.gguf"},
+ func(s *S3FileStager) error {
+ _, err := s.EnsureRemote(context.Background(), nodeID, local, storage.ModelKey("m.gguf"))
+ return err
+ }),
+ Entry("temp", workerctl.PathFilesTemp, fileTempReply{LocalPath: "/w/tmp/x"},
+ func(s *S3FileStager) error {
+ _, err := s.AllocRemoteTemp(context.Background(), nodeID)
+ return err
+ }),
+ Entry("listdir", workerctl.PathFilesListDir, fileListDirReply{Files: []string{"a", "b"}},
+ func(s *S3FileStager) error {
+ _, err := s.ListRemoteDir(context.Background(), nodeID, "models/m")
+ return err
+ }),
+ Entry("stage", workerctl.PathFilesStage, fileStageReply{Key: "data/out"},
+ func(s *S3FileStager) error {
+ return s.StageRemoteToStore(context.Background(), nodeID, "/w/models/out", "data/out")
+ }),
+ )
+
+ // THE rule of this change, and it is written out at five separate call
+ // sites: the RPC's budget is DERIVED FROM the caller's context, never
+ // started fresh from a background one. A site that started fresh would keep
+ // commanding a worker after its caller had given up, and would report the
+ // worker's late answer as a live one. Each site is pinned on its own,
+ // because five sites behind one spec is four sites nothing holds.
+ DescribeTable("never reaches the worker once the caller's context is spent",
+ func(call func(context.Context, *S3FileStager) error) {
+ // Every verb is scripted to answer, so the ONLY thing that can stop
+ // the call is the caller's own spent context.
+ for _, p := range []string{
+ workerctl.PathFilesEnsure, workerctl.PathFilesStage,
+ workerctl.PathFilesTemp, workerctl.PathFilesListDir,
+ } {
+ workers.scriptRawReply(controlKey(nodeID, p), []byte(`{}`))
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := call(ctx, stager)
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got %v", err)
+ // An expiry is never evidence about a file, so it must arrive
+ // wearing the umbrella that stops a caller acting on it.
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue(), "got %v", err)
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ Expect(workers.callSubjects()).To(BeEmpty())
+ },
+ Entry("ensure", func(ctx context.Context, s *S3FileStager) error {
+ _, err := s.EnsureRemote(ctx, nodeID, local, storage.ModelKey("m.gguf"))
+ return err
+ }),
+ Entry("temp", func(ctx context.Context, s *S3FileStager) error {
+ _, err := s.AllocRemoteTemp(ctx, nodeID)
+ return err
+ }),
+ Entry("listdir", func(ctx context.Context, s *S3FileStager) error {
+ _, err := s.ListRemoteDir(ctx, nodeID, "models/m")
+ return err
+ }),
+ Entry("stage to store", func(ctx context.Context, s *S3FileStager) error {
+ return s.StageRemoteToStore(ctx, nodeID, "/w/models/out", "data/out")
+ }),
+ Entry("fetch", func(ctx context.Context, s *S3FileStager) error {
+ return s.FetchRemote(ctx, nodeID, "/w/models/out", filepath.Join(GinkgoT().TempDir(), "dst"))
+ }),
+ Entry("fetch by key", func(ctx context.Context, s *S3FileStager) error {
+ return s.FetchRemoteByKey(ctx, nodeID, "data/out", filepath.Join(GinkgoT().TempDir(), "dst"))
+ }),
+ )
+
+ // The other half of the same distinction, also at every site: what the
+ // WORKER said comes back as the worker's answer, so a caller may act on it,
+ // and it must not be dressed up as a route failure.
+ DescribeTable("reports the worker's own refusal as the worker's answer",
+ func(path string, call func(*S3FileStager) error) {
+ workers.scriptRawReply(controlKey(nodeID, path), []byte(`{"error":"no space left on device"}`))
+ err := call(stager)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no space left on device"))
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeFalse(), "got %v", err)
+ },
+ Entry("ensure", workerctl.PathFilesEnsure, func(s *S3FileStager) error {
+ _, err := s.EnsureRemote(context.Background(), nodeID, local, storage.ModelKey("m.gguf"))
+ return err
+ }),
+ Entry("temp", workerctl.PathFilesTemp, func(s *S3FileStager) error {
+ _, err := s.AllocRemoteTemp(context.Background(), nodeID)
+ return err
+ }),
+ Entry("listdir", workerctl.PathFilesListDir, func(s *S3FileStager) error {
+ _, err := s.ListRemoteDir(context.Background(), nodeID, "models/m")
+ return err
+ }),
+ Entry("stage to store", workerctl.PathFilesStage, func(s *S3FileStager) error {
+ return s.StageRemoteToStore(context.Background(), nodeID, "/w/models/out", "data/out")
+ }),
+ Entry("fetch", workerctl.PathFilesStage, func(s *S3FileStager) error {
+ return s.FetchRemote(context.Background(), nodeID, "/w/models/out",
+ filepath.Join(GinkgoT().TempDir(), "dst"))
+ }),
+ )
+
+ // A worker too old to serve a file verb answers 404. That is a DEPLOYMENT
+ // fact about the worker's build and says nothing about the file, so it must
+ // not reach a caller as "that file is not there".
+ It("reports a worker that serves no file verbs as unsupported, not as an absent file", func() {
+ workers.scriptUnsupported(controlKey(nodeID, workerctl.PathFilesListDir))
+ _, err := stager.ListRemoteDir(context.Background(), nodeID, "models/m")
+ Expect(err).To(MatchError(ErrWorkerControlUnsupported))
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+
+ It("reports a worker it has no route to as unroutable", func() {
+ workers.scriptUnroutable(nodeID)
+ _, err := stager.AllocRemoteTemp(context.Background(), nodeID)
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue(), "got %v", err)
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+})
diff --git a/core/services/nodes/file_stager_verify_deadline_test.go b/core/services/nodes/file_stager_verify_deadline_test.go
index 0827bbecdc56..0239b832e7f8 100644
--- a/core/services/nodes/file_stager_verify_deadline_test.go
+++ b/core/services/nodes/file_stager_verify_deadline_test.go
@@ -65,7 +65,7 @@ var _ = Describe("staging verify phase and the cold-load stall window", func() {
return "", err
}
return u.Host, nil
- }, "")
+ }, "", directNetDialerFor)
}
It("survives a run of verified-and-skipped shards that upload no bytes at all", func() {
diff --git a/core/services/nodes/file_staging_client.go b/core/services/nodes/file_staging_client.go
index bfc202c8205d..45dbe3a57f2f 100644
--- a/core/services/nodes/file_staging_client.go
+++ b/core/services/nodes/file_staging_client.go
@@ -22,27 +22,31 @@ import (
// for distributed mode. Input files are staged on the backend node before the
// gRPC call. Output files are retrieved from the backend after the call.
//
-// Uses the FileStager interface — agnostic to transport (S3+NATS or gRPC).
+// Uses the FileStager interface — agnostic to transport (an object store, or
+// direct HTTP to the worker), and in both cases reached over the worker's
+// tunnel.
// The caller gets a grpc.Backend that behaves identically to a local one —
// no changes needed in core/backend/*.go.
//
// Methods that require no file staging are inherited from the embedded
// grpc.Backend; only methods with staging logic are overridden below.
type FileStagingClient struct {
- grpc.Backend // embedded for pass-through of non-staging methods
- stager FileStager
- nodeID string
+ grpc.WrappedBackend // pass-through of non-staging methods, plus Unwrap
+ stager FileStager
+ nodeID string
mu sync.RWMutex
remoteModelPath string // set during LoadModel from staged ModelPath
}
+var _ grpc.BackendUnwrapper = (*FileStagingClient)(nil)
+
// NewFileStagingClient creates a new file staging wrapper.
func NewFileStagingClient(inner grpc.Backend, stager FileStager, nodeID string) *FileStagingClient {
return &FileStagingClient{
- Backend: inner,
- stager: stager,
- nodeID: nodeID,
+ WrappedBackend: grpc.WrappedBackend{Backend: inner},
+ stager: stager,
+ nodeID: nodeID,
}
}
diff --git a/core/services/nodes/file_transfer_server.go b/core/services/nodes/file_transfer_server.go
index 0fc5ac6343f4..3f3a4ebcce0c 100644
--- a/core/services/nodes/file_transfer_server.go
+++ b/core/services/nodes/file_transfer_server.go
@@ -42,17 +42,35 @@ const (
targetSidecarSuffix = ".sha256.target"
)
+// AuthenticatedRoutes is a set of extra routes to serve on the worker's HTTP
+// server, mounted under Prefix and behind the SAME bearer check as the file
+// routes.
+//
+// It is a mount request rather than a plain func(*http.ServeMux) so this
+// package, and not its caller, owns the authentication. A caller handed the
+// server's own mux would be registering handlers alongside the file routes, and
+// forgetting the token check in one of them would be an unauthenticated verb
+// rather than a compile error. Register is instead given a mux of its own,
+// which is reachable only through the check below.
+type AuthenticatedRoutes struct {
+ // Prefix is the single path prefix every registered route lives under.
+ Prefix string
+ // Register mounts the routes on a mux private to this route set.
+ Register func(*http.ServeMux)
+}
+
// StartFileTransferServer starts a small HTTP server for file transfer in distributed mode.
// It provides PUT/GET/POST endpoints for uploading, downloading, and allocating temp files,
// as well as backend log REST and WebSocket endpoints when logStore is non-nil.
// Auth is via Bearer token (registration token), using constant-time comparison.
// A nil readiness fails open, keeping /readyz's historical always-200 answer.
-func StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, logStore ...*model.BackendLogStore) (*http.Server, error) {
+// A nil extra mounts no additional routes.
+func StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, extra *AuthenticatedRoutes, logStore ...*model.BackendLogStore) (*http.Server, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
- return StartFileTransferServerWithReadiness(listener, stagingDir, modelsDir, dataDir, token, maxUploadSize, readiness, logStore...)
+ return StartFileTransferServerWithRoutes(listener, stagingDir, modelsDir, dataDir, token, maxUploadSize, readiness, extra, logStore...)
}
// StartFileTransferServerWithListener starts the server on an existing listener.
@@ -66,6 +84,25 @@ func StartFileTransferServerWithListener(lis net.Listener, stagingDir, modelsDir
// the probe keeps its historical always-200 behaviour for callers that have no
// meaningful readiness signal to report.
func StartFileTransferServerWithReadiness(lis net.Listener, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, logStore ...*model.BackendLogStore) (*http.Server, error) {
+ return StartFileTransferServerWithRoutes(lis, stagingDir, modelsDir, dataDir, token, maxUploadSize, readiness, nil, logStore...)
+}
+
+// StartFileTransferServerWithRoutes is StartFileTransferServerWithReadiness
+// plus an extra authenticated route set. See AuthenticatedRoutes.
+func StartFileTransferServerWithRoutes(lis net.Listener, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, extra *AuthenticatedRoutes, logStore ...*model.BackendLogStore) (*http.Server, error) {
+ // Checked before anything is created. A route set that names no prefix or
+ // no registrar is a caller bug, and mounting nothing for it would be the
+ // worst possible answer: the server comes up healthy and every route the
+ // caller believes it registered answers 404, which through a tunnel is
+ // indistinguishable from a version skew.
+ if extra != nil {
+ if extra.Prefix == "" {
+ return nil, fmt.Errorf("extra routes were given no prefix to mount under")
+ }
+ if extra.Register == nil {
+ return nil, fmt.Errorf("extra routes under %q were given no registrar", extra.Prefix)
+ }
+ }
if err := os.MkdirAll(stagingDir, 0750); err != nil {
return nil, fmt.Errorf("creating staging dir %s: %w", stagingDir, err)
}
@@ -165,8 +202,27 @@ func StartFileTransferServerWithReadiness(lis net.Listener, stagingDir, modelsDi
// Readiness: "can this worker actually accept work?" See WorkerReadiness.
mux.HandleFunc("/readyz", probe(readiness.Check))
+ if extra != nil {
+ extraMux := http.NewServeMux()
+ extra.Register(extraMux)
+ mux.Handle(extra.Prefix, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !checkBearerToken(r, token) {
+ xlog.Debug("worker HTTP server: unauthorized request on an extra route",
+ "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ extraMux.ServeHTTP(w, r)
+ }))
+ }
+
addr := lis.Addr().String()
server := &http.Server{
+ // Addr is informational here: Serve takes the listener, not this
+ // field. It is set so a caller that asked for port 0 can learn the
+ // port it actually got without threading a second return value
+ // through every wrapper above.
+ Addr: addr,
Handler: mux,
ReadHeaderTimeout: 30 * time.Second, // prevent slowloris; does not affect body reads
}
@@ -839,6 +895,17 @@ func handleBackendLogsWS(w http.ResponseWriter, r *http.Request, logStore *model
conn := &backendLogsWSConn{Conn: ws}
+ // KNOWN RACE: the snapshot is sent before the subscription is registered, so
+ // a line appended in that window is never streamed. A viewer attaching while
+ // a model loads (when a backend is at its noisiest) can silently miss lines;
+ // they stay in the buffer, so a reload shows them. Fixing it needs an atomic
+ // snapshot-plus-subscribe held under the buffer's own lock (buf.mu in
+ // pkg/model/backend_log_store.go), because that is the lock AppendLine takes
+ // while it enqueues and fans out to subscribers. The store-level s.mu guards
+ // only the buffers map and excludes nothing an appender does, so taking it
+ // leaves this race exactly where it is. Reordering these two calls is not a
+ // fix either: it would duplicate instead of drop.
+
// Send existing lines as initial batch
existingLines := logStore.GetLines(modelID)
initialMsg := map[string]any{
diff --git a/core/services/nodes/file_transfer_server_test.go b/core/services/nodes/file_transfer_server_test.go
index 78afb293b777..383d04b27bb9 100644
--- a/core/services/nodes/file_transfer_server_test.go
+++ b/core/services/nodes/file_transfer_server_test.go
@@ -21,6 +21,50 @@ import (
. "github.com/onsi/gomega"
)
+// directNetDialerFor is the dial function these specs give the stager.
+//
+// The stager exists to reach a worker over that worker's TUNNEL, and it refuses
+// to reach one at all without a dialer. These specs are about the HTTP protocol
+// between the stager and the file-transfer server, and they run that server on
+// loopback, so a plain TCP dial is what stands in for the tunnel here. Nothing
+// in production supplies this: see the wiring in core/application.
+func directNetDialerFor(_ string) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext
+}
+
+var _ = Describe("The HTTP file stager without a worker dialer", func() {
+ // Every request refused, none sent. Staging reaches a worker over that
+ // worker's tunnel, and a stager that fell back to connecting to the
+ // registered address would move gigabytes over a path that exists only
+ // while workers still listen on one.
+ newBare := func() *HTTPFileStager {
+ return NewHTTPFileStager(func(string) (string, error) { return "127.0.0.1:1", nil }, "tok", nil)
+ }
+
+ It("refuses to upload", func() {
+ local := filepath.Join(GinkgoT().TempDir(), "f.bin")
+ Expect(os.WriteFile(local, []byte("payload"), 0o600)).To(Succeed())
+ _, err := newBare().EnsureRemote(context.Background(), "node-1", local, "f.bin")
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("refuses to download", func() {
+ dst := filepath.Join(GinkgoT().TempDir(), "out.bin")
+ Expect(newBare().FetchRemoteByKey(context.Background(), "node-1", "f.bin", dst)).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("refuses to allocate a remote temp file", func() {
+ _, err := newBare().AllocRemoteTemp(context.Background(), "node-1")
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+
+ It("refuses to list a remote directory", func() {
+ _, err := newBare().ListRemoteDir(context.Background(), "node-1", "models/")
+ Expect(err).To(MatchError(ErrNoWorkerDialer))
+ })
+})
+
var _ = Describe("FileTransferServer", func() {
setupTestServer := func(token string, maxUploadSize int64) (*httptest.Server, string, string, string) {
stagingDir := GinkgoT().TempDir()
@@ -459,7 +503,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "tok")
+ }, "tok", directNetDialerFor)
remotePath, err := stager.EnsureRemote(context.Background(), "node-1", localPath, "present.bin")
Expect(err).ToNot(HaveOccurred())
@@ -488,7 +532,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "tok")
+ }, "tok", directNetDialerFor)
remotePath, err := stager.EnsureRemote(context.Background(), "node-1", localPath, "changed.bin")
Expect(err).ToNot(HaveOccurred())
@@ -517,7 +561,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "tok")
+ }, "tok", directNetDialerFor)
remotePath, err := stager.EnsureRemote(context.Background(), "node-1", localPath, "new.bin")
Expect(err).ToNot(HaveOccurred())
@@ -553,7 +597,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "")
+ }, "", directNetDialerFor)
remotePath, err := stager.EnsureRemote(context.Background(), "node-1", localPath, "compat.bin")
Expect(err).ToNot(HaveOccurred())
@@ -770,7 +814,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "tok")
+ }, "tok", directNetDialerFor)
remotePath, err := stager.EnsureRemote(context.Background(), "node-1", localPath, "resume.bin")
Expect(err).ToNot(HaveOccurred())
@@ -868,7 +912,7 @@ var _ = Describe("FileTransferServer", func() {
addr := strings.TrimPrefix(ts.URL, "http://")
stager := NewHTTPFileStager(func(nodeID string) (string, error) {
return addr, nil
- }, "tok")
+ }, "tok", directNetDialerFor)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
diff --git a/core/services/nodes/health.go b/core/services/nodes/health.go
index ffe1cfa0e2e5..e05e33d48e33 100644
--- a/core/services/nodes/health.go
+++ b/core/services/nodes/health.go
@@ -7,7 +7,9 @@ import (
"sync"
"time"
+ "github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/advisorylock"
+ "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/xlog"
"gorm.io/gorm"
)
@@ -39,17 +41,34 @@ type HealthMonitor struct {
autoOffline bool // mark stale nodes as offline (preserves approval status)
clientFactory BackendClientFactory // creates gRPC backend clients
perModelHealthCheck bool // check each model's backend process individually
- missesMu sync.Mutex
- misses map[modelKey]int // consecutive failed-probe counts; reset on success or model removal
- cancel context.CancelFunc
- cancelMu sync.Mutex
+ // presence and reconnectGrace are the second liveness mechanism. The
+ // heartbeat says the worker's supervisor is alive; presence says whether
+ // anything in this deployment can still reach its backends. See
+ // tunnelDeparted. nil disables the second mechanism entirely.
+ presence NodePresenceReader
+ reconnectGrace time.Duration
+ missesMu sync.Mutex
+ misses map[modelKey]int // consecutive failed-probe counts; reset on success or model removal
+ cancel context.CancelFunc
+ cancelMu sync.Mutex
}
// NewHealthMonitor creates a new HealthMonitor.
// If db is non-nil (PostgreSQL), an advisory lock is used so that only one
// frontend instance runs health checks at a time in distributed mode.
-// If clientFactory is nil, a default factory using the given authToken is used.
-func NewHealthMonitor(registry NodeHealthStore, db *gorm.DB, checkInterval, staleThreshold time.Duration, authToken string, perModelHealthCheck bool, clientFactory ...BackendClientFactory) *HealthMonitor {
+// clientFactory is what reaches a worker's backends, over that worker's tunnel.
+// Omitting it (or passing nil) leaves the monitor with a factory that refuses
+// every request, so per-model probes are skipped and logged rather than counted
+// as misses; authToken is then only the credential a working factory would have
+// carried. Production always passes one.
+//
+// presence and reconnectGrace are a REQUIRED positional pair rather than another
+// optional tail, so a caller has to decide rather than inherit a nil. A nil
+// reader means this deployment has nothing that can say a worker is gone, which
+// is correct for a single-node install and wrong for a distributed one; a zero
+// grace with a non-nil reader takes the documented default, because a zero
+// window would make every departure a verdict the instant it was stamped.
+func NewHealthMonitor(registry NodeHealthStore, db *gorm.DB, checkInterval, staleThreshold time.Duration, authToken string, perModelHealthCheck bool, presence NodePresenceReader, reconnectGrace time.Duration, clientFactory ...BackendClientFactory) *HealthMonitor {
checkInterval = cmp.Or(checkInterval, 15*time.Second)
staleThreshold = cmp.Or(staleThreshold, 60*time.Second)
var factory BackendClientFactory
@@ -66,10 +85,51 @@ func NewHealthMonitor(registry NodeHealthStore, db *gorm.DB, checkInterval, stal
autoOffline: true,
clientFactory: factory,
perModelHealthCheck: perModelHealthCheck,
+ presence: presence,
+ reconnectGrace: cmp.Or(reconnectGrace, config.DefaultWorkerReconnectGrace),
misses: make(map[modelKey]int),
}
}
+// ReadsAbsence reports whether this monitor has a source for the second
+// liveness mechanism.
+//
+// It exists to be asserted at wiring time (see core/application), and that is
+// worth stating because it is the only symptom the wiring has. A monitor built
+// without a presence reader does not fail, log, or behave oddly: it reports a
+// worker whose tunnel died an hour ago as healthy, forever, which is exactly
+// what a healthy fleet looks like.
+func (hm *HealthMonitor) ReadsAbsence() bool { return hm != nil && hm.presence != nil }
+
+// tunnelDeparted reports whether this deployment has decided that a node's
+// tunnel is gone: no live replica holds it and the departure has outlived the
+// reconnect grace.
+//
+// Only cluster.PresenceGone answers true. PresenceReconnecting is a worker
+// re-dialling right now, PresenceUnknown is a worker that has never dialled or
+// whose departure aged out, and a query that FAILS is not an answer at all.
+// Acting on any of those would demote a fleet for a reason that has nothing to
+// do with any worker, which is the collapse this whole mechanism replaced.
+//
+// Backend workers only. An agent worker holds no tunnel at all, so it has no
+// departure to measure and would answer PresenceUnknown anyway; the type check
+// is here to save the query rather than to add a second rule.
+func (hm *HealthMonitor) tunnelDeparted(ctx context.Context, node *BackendNode) bool {
+ if hm.presence == nil || node == nil {
+ return false
+ }
+ if node.NodeType != "" && node.NodeType != NodeTypeBackend {
+ return false
+ }
+ p, err := hm.presence.Presence(ctx, node.ID, hm.reconnectGrace)
+ if err != nil {
+ xlog.Warn("Health monitor could not read node presence; leaving the node's status alone",
+ "node", node.Name, "nodeID", node.ID, "error", err)
+ return false
+ }
+ return p == cluster.PresenceGone
+}
+
// Start begins the health monitoring loop in a background goroutine.
// If a previous instance is running, it is stopped first.
func (hm *HealthMonitor) Start(ctx context.Context) {
@@ -165,7 +225,42 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
continue
}
- // Heartbeat is fresh — node is alive
+ // The heartbeat is fresh, so the worker's supervisor is alive. That is
+ // NOT the same as this deployment being able to reach its backends:
+ // those are reached over the worker's TUNNEL, and a worker can
+ // heartbeat forever with no tunnel at all (a proxy that stopped
+ // upgrading WebSockets, a rotated registration credential, a reconnect
+ // loop longer than the grace).
+ //
+ // Before this check the two were conflated and the result was a node
+ // wedged in plain sight: listed healthy, heartbeating, with every
+ // request for a model already loaded on it failing "no route to that
+ // worker" indefinitely. Nothing reaped it, because every reaper keys on
+ // the heartbeat and the heartbeat was fine.
+ //
+ // Demoted and not marked offline, deliberately. MarkUnhealthy is
+ // status-only, and status is enough: routing and eviction both select
+ // on status=healthy, so the loaded rows stop being chosen and the model
+ // is placed somewhere reachable on the next request. MarkOffline would
+ // DELETE this node's rows, and deleting rows on a presence read would
+ // give any future defect in that read the largest blast radius in the
+ // system for no gain the demotion does not already deliver.
+ if hm.tunnelDeparted(ctx, &node) {
+ if node.Status != StatusUnhealthy && node.Status != StatusOffline {
+ xlog.Warn("Node is heartbeating but its tunnel has been gone longer than the reconnect grace; marking unhealthy",
+ "node", node.Name, "nodeID", node.ID, "grace", hm.reconnectGrace)
+ if err := hm.registry.MarkUnhealthy(ctx, node.ID); err != nil {
+ xlog.Error("Failed to mark a departed node unhealthy", "node", node.Name, "error", err)
+ }
+ }
+ // No re-promotion, and no per-model probes. The probes would dial a
+ // worker there is no route to, once per model per tick, and decline
+ // to count any of it; the re-promotion below is what used to undo
+ // this demotion on the very next tick.
+ continue
+ }
+
+ // Heartbeat is fresh and the tunnel is not gone: the node is alive
if node.Status == StatusUnhealthy || node.Status == StatusOffline {
xlog.Info("Node recovered", "node", node.Name)
if err := hm.registry.MarkHealthy(ctx, node.ID); err != nil {
@@ -181,16 +276,47 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
if hm.perModelHealthCheck {
models, _ := hm.registry.GetNodeModels(ctx, node.ID)
for _, m := range models {
- if m.Address == "" || m.Address == node.Address {
+ // A row with no address names no backend process, so there is
+ // nothing to probe. The old second arm of this test skipped a
+ // replica whose address equalled the NODE's; a node has no
+ // address any more, so that comparison could only ever be true
+ // for two empty strings and has been dropped rather than left
+ // to read as a live rule.
+ if m.WorkerLocalAddress == "" {
+ continue
+ }
+ // Through the node's tunnel, never a direct dial to m.WorkerLocalAddress:
+ // that address is a port inside the worker. A worker this
+ // replica cannot reach is not evidence that its backend died,
+ // so the miss counter is left alone and the row survives;
+ // counting it as a miss would reap live models across the whole
+ // fleet the moment the tunnel wiring was wrong.
+ mClient, err := hm.clientFactory.NewClientForNode(node.ID, m.WorkerLocalAddress, false)
+ if err != nil {
+ xlog.Error("Skipping model health probe: no way to reach the worker",
+ "node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", err)
continue
}
- mClient := hm.clientFactory.NewClient(m.Address, false)
mCheckCtx, mCancel := context.WithTimeout(ctx, 5*time.Second)
ok, _ := mClient.HealthCheck(mCheckCtx)
mCancel()
+ // Asked BEFORE the client is closed, because closing is what
+ // would discard the transport's record of why it failed.
+ unreached := unroutable(mClient)
if closer, ok := mClient.(io.Closer); ok {
closer.Close()
}
+ if unreached != nil {
+ // The probe never reached a backend, so it observed
+ // nothing. The miss streak is left exactly as it was:
+ // neither advanced, which after three passes would delete
+ // this row and every other row in the fleet the moment a
+ // peer link blipped, nor cleared, which would forgive a
+ // backend that really has died.
+ xlog.Warn("Could not probe a model backend: no route to the worker",
+ "node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", unreached)
+ continue
+ }
key := modelKey{NodeID: node.ID, ModelName: m.ModelName, ReplicaIndex: m.ReplicaIndex}
hm.missesMu.Lock()
@@ -207,12 +333,12 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
if misses < perModelMissThreshold {
xlog.Debug("Model backend probe failed, awaiting threshold before removal",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
- "address", m.Address, "misses", misses, "threshold", perModelMissThreshold)
+ "address", m.WorkerLocalAddress, "misses", misses, "threshold", perModelMissThreshold)
continue
}
xlog.Warn("Model backend unhealthy after consecutive misses, removing from registry",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
- "address", m.Address, "misses", misses)
+ "address", m.WorkerLocalAddress, "misses", misses)
if err := hm.registry.RemoveNodeModel(ctx, node.ID, m.ModelName, m.ReplicaIndex); err != nil {
xlog.Warn("Failed to remove unhealthy model from registry",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", err)
diff --git a/core/services/nodes/health_mock_test.go b/core/services/nodes/health_mock_test.go
index c52712dab5ff..592f30d2e083 100644
--- a/core/services/nodes/health_mock_test.go
+++ b/core/services/nodes/health_mock_test.go
@@ -133,8 +133,17 @@ func (f *fakeNodeHealthStore) RemoveNodeModel(_ context.Context, nodeID, modelNa
type fakeBackendClient struct {
healthy bool
err error
+ // dialErr makes this client report that its TRANSPORT failed, which is what
+ // a real client whose tunnel dial failed does. It is the half of
+ // unroutability that a refusing factory cannot stand in for, and the likely
+ // one in production: the factory only fails when the wiring is absent.
+ dialErr error
}
+// LastDialError satisfies grpc.DialErrorReporter so a spec can drive the
+// "reached no backend" branch without a real tunnel.
+func (c *fakeBackendClient) LastDialError() error { return c.dialErr }
+
func (c *fakeBackendClient) IsBusy() bool { return false }
func (c *fakeBackendClient) HealthCheck(_ context.Context) (bool, error) {
return c.healthy, c.err
@@ -300,6 +309,15 @@ type fakeBackendClientFactory struct {
clients map[string]*fakeBackendClient
// default client returned when address not in clients map
defaultClient *fakeBackendClient
+ // forNode records every node id NewClientForNode was asked for.
+ forNode []string
+ // forNodeAddr records the ADDRESS asked for alongside each node id, so a
+ // spec can pin which of the two addresses a caller reached for: a replica
+ // row's own, or the node's, the second of which is now always empty.
+ forNodeAddr []string
+ // refuseForNode makes NewClientForNode fail, standing in for a deployment
+ // with no way to reach the worker. Set before the code under test runs.
+ refuseForNode error
}
func newFakeBackendClientFactory() *fakeBackendClientFactory {
@@ -324,6 +342,32 @@ func (f *fakeBackendClientFactory) NewClient(address string, _ bool) grpc.Backen
return f.defaultClient
}
+// nodesSeen records the node ids the code under test asked for, so a spec can
+// assert a caller reached a worker through its NODE rather than by address.
+func (f *fakeBackendClientFactory) nodesSeen() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.forNode...)
+}
+
+// addressesSeen records the addresses passed alongside those node ids.
+func (f *fakeBackendClientFactory) addressesSeen() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.forNodeAddr...)
+}
+
+func (f *fakeBackendClientFactory) NewClientForNode(nodeID, address string, parallel bool) (grpc.Backend, error) {
+ if f.refuseForNode != nil {
+ return nil, f.refuseForNode
+ }
+ f.mu.Lock()
+ f.forNode = append(f.forNode, nodeID)
+ f.forNodeAddr = append(f.forNodeAddr, address)
+ f.mu.Unlock()
+ return f.NewClient(address, parallel), nil
+}
+
// helper to make a BackendNode with given properties
func makeTestNode(id, name, address string, status string, lastHeartbeat time.Time) *BackendNode {
return &BackendNode{
@@ -368,4 +412,5 @@ func freshTime() time.Time {
// Compile-time interface checks
var _ NodeHealthStore = (*fakeNodeHealthStore)(nil)
var _ BackendClientFactory = (*fakeBackendClientFactory)(nil)
+var _ grpc.DialErrorReporter = (*fakeBackendClient)(nil)
var _ grpc.Backend = (*fakeBackendClient)(nil)
diff --git a/core/services/nodes/health_test.go b/core/services/nodes/health_test.go
index c78ccfffe0d4..089ca02162a4 100644
--- a/core/services/nodes/health_test.go
+++ b/core/services/nodes/health_test.go
@@ -2,6 +2,7 @@ package nodes
import (
"context"
+ "errors"
"fmt"
"runtime"
"time"
@@ -9,6 +10,9 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/cluster"
+
"github.com/mudler/LocalAI/core/services/testutil"
"gorm.io/gorm"
)
@@ -31,7 +35,7 @@ var _ = Describe("HealthMonitor", func() {
// Use a 30-second stale threshold for tests.
// Pass nil db to avoid advisory lock path (no distributed mode in tests).
- hm = NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false)
+ hm = NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false, nil, 0)
})
makeNode := func(name, address string, vram uint64) *BackendNode {
@@ -243,7 +247,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
// node should remain healthy because heartbeat is fresh
node := makeTestNode("node-crash", "crash-worker", "10.0.0.9:50051", StatusHealthy, freshTime())
store.addNode(node)
- store.addNodeModel("node-crash", NodeModel{NodeID: "node-crash", ModelName: "piper-model", Address: "10.0.0.9:50053"})
+ store.addNodeModel("node-crash", NodeModel{NodeID: "node-crash", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.9:50053"})
// gRPC backend is dead — but health is heartbeat-based, not gRPC-based
factory.setClient("10.0.0.9:50051", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
@@ -263,7 +267,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-model", "model-worker", "10.0.0.10:50051", StatusHealthy, freshTime())
store.addNode(node)
- store.addNodeModel("node-model", NodeModel{NodeID: "node-model", ModelName: "piper-model", Address: "10.0.0.10:50053"})
+ store.addNodeModel("node-model", NodeModel{NodeID: "node-model", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.10:50053"})
// Model backend is dead
factory.setClient("10.0.0.10:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
@@ -285,6 +289,93 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
})
+ It("probes a model through its NODE, never by dialling the stored address", func() {
+ // The address on a NodeModel row is a port inside the worker. This
+ // frontend reaches it over the worker's tunnel, so the node has to
+ // be part of every probe; a probe built from the address alone is
+ // the direct dial the tunnel replaces.
+ store := newFakeNodeHealthStore()
+ factory := newFakeBackendClientFactory()
+ hm := newTestHealthMonitor(store, factory, true, staleThreshold)
+ hm.perModelHealthCheck = true
+
+ node := makeTestNode("node-tun", "tun-worker", "10.0.0.20:50051", StatusHealthy, freshTime())
+ store.addNode(node)
+ store.addNodeModel("node-tun", NodeModel{NodeID: "node-tun", ModelName: "m", WorkerLocalAddress: "10.0.0.20:50053"})
+
+ hm.doCheckAll(context.Background())
+ Expect(factory.nodesSeen()).To(ContainElement("node-tun"))
+ })
+
+ It("leaves a model row alone when it cannot reach the worker at all", func() {
+ // Not a miss. A frontend with no way to reach a worker has learned
+ // nothing about that worker's backends, and counting it as a failed
+ // probe would reap every model in the fleet the moment the tunnel
+ // wiring broke.
+ store := newFakeNodeHealthStore()
+ factory := newFakeBackendClientFactory()
+ factory.refuseForNode = fmt.Errorf("no tunnel for you")
+ hm := newTestHealthMonitor(store, factory, true, staleThreshold)
+ hm.perModelHealthCheck = true
+
+ node := makeTestNode("node-cut", "cut-worker", "10.0.0.21:50051", StatusHealthy, freshTime())
+ store.addNode(node)
+ store.addNodeModel("node-cut", NodeModel{NodeID: "node-cut", ModelName: "m", WorkerLocalAddress: "10.0.0.21:50053"})
+
+ for i := 0; i < perModelMissThreshold+1; i++ {
+ hm.doCheckAll(context.Background())
+ }
+ Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("RemoveNodeModel")))
+ Expect(store.getNode("node-cut").Status).To(Equal(StatusHealthy))
+ })
+
+ It("leaves a model row alone when the probe never reached the worker", func() {
+ // The sibling of the factory case above, and the likelier one. The
+ // client is built fine and the tunnel DIAL fails, which gRPC
+ // reports with the same code as a dead backend. Counted as a miss
+ // it would delete every model row in the fleet after three passes
+ // of a peer link blip, while the models kept serving.
+ store := newFakeNodeHealthStore()
+ factory := newFakeBackendClientFactory()
+ hm := newTestHealthMonitor(store, factory, true, staleThreshold)
+ hm.perModelHealthCheck = true
+
+ node := makeTestNode("node-blip", "blip-worker", "10.0.0.22:50051", StatusHealthy, freshTime())
+ store.addNode(node)
+ store.addNodeModel("node-blip", NodeModel{NodeID: "node-blip", ModelName: "m", WorkerLocalAddress: "10.0.0.22:50053"})
+ factory.setClient("10.0.0.22:50053", &fakeBackendClient{
+ healthy: false,
+ err: fmt.Errorf("connection error"),
+ dialErr: fmt.Errorf("%w: %w", cluster.ErrNoRoute, cluster.ErrPeerUnreachable),
+ })
+
+ for i := 0; i < perModelMissThreshold+2; i++ {
+ hm.doCheckAll(context.Background())
+ }
+ Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("RemoveNodeModel")))
+ })
+
+ It("still reaps a backend that died on a worker it CAN reach", func() {
+ // The other direction, so the new check cannot pass by never
+ // reaping. A dial that succeeded and an RPC that failed is a dead
+ // process, and its row must still go.
+ store := newFakeNodeHealthStore()
+ factory := newFakeBackendClientFactory()
+ hm := newTestHealthMonitor(store, factory, true, staleThreshold)
+ hm.perModelHealthCheck = true
+
+ node := makeTestNode("node-dead", "dead-worker", "10.0.0.23:50051", StatusHealthy, freshTime())
+ store.addNode(node)
+ store.addNodeModel("node-dead", NodeModel{NodeID: "node-dead", ModelName: "m", WorkerLocalAddress: "10.0.0.23:50053"})
+ // No dialErr: the transport was fine.
+ factory.setClient("10.0.0.23:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
+
+ for i := 0; i < perModelMissThreshold; i++ {
+ hm.doCheckAll(context.Background())
+ }
+ Expect(store.getCalls()).To(ContainElement("RemoveNodeModel:node-dead:m:0"))
+ })
+
It("preserves model row when an intermittent failure is followed by a success", func() {
store := newFakeNodeHealthStore()
factory := newFakeBackendClientFactory()
@@ -293,7 +384,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-flap", "flap-worker", "10.0.0.11:50051", StatusHealthy, freshTime())
store.addNode(node)
- store.addNodeModel("node-flap", NodeModel{NodeID: "node-flap", ModelName: "piper-model", Address: "10.0.0.11:50053"})
+ store.addNodeModel("node-flap", NodeModel{NodeID: "node-flap", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.11:50053"})
deadClient := &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")}
liveClient := &fakeBackendClient{healthy: true}
@@ -317,3 +408,207 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
})
})
})
+
+// The wedge this check exists to end.
+//
+// A worker's heartbeat says its supervisor is alive. It says nothing about
+// whether anything in this deployment can reach the worker's BACKENDS, because
+// those are reached over the worker's tunnel. Before these specs the two were
+// conflated, and a worker that heartbeated with a permanently dead tunnel (a
+// proxy that stopped upgrading WebSockets, a rotated credential, a reconnect
+// loop longer than the grace) stayed listed HEALTHY forever while every request
+// for a model already loaded on it failed "no route to that worker". No reaper
+// was scheduled for it: every reaper keys on the heartbeat, and the heartbeat
+// was fine.
+//
+// Driven through a real cluster.Registry with the departures aged on the
+// DATABASE clock, because a double that answers a Presence value cannot show
+// that the window is measured where every replica agrees on it.
+var _ = Describe("HealthMonitor and a worker whose tunnel is gone", func() {
+ var (
+ ctx context.Context
+ db *gorm.DB
+ registry *NodeRegistry
+ clusterR *cluster.Registry
+ hm *HealthMonitor
+ )
+
+ const (
+ grace = 60 * time.Second
+ instance = "inst-health"
+ )
+
+ BeforeEach(func() {
+ if runtime.GOOS == "darwin" {
+ Skip("testcontainers requires Docker, not available on macOS CI")
+ }
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ var err error
+ registry, err = NewNodeRegistry(db)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ clusterR = cluster.NewRegistry(db)
+ Expect(clusterR.Register(ctx, instance, "10.0.0.1:8080", "v1")).To(Succeed())
+ hm = NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false, clusterR, grace)
+ })
+
+ // register creates a heartbeating backend worker. Its heartbeat stays fresh
+ // for the whole spec, which is the precondition the wedge needs: a stale
+ // heartbeat would take the OTHER branch and prove nothing about this one.
+ register := func(name string) *BackendNode {
+ GinkgoHelper()
+ node := &BackendNode{Name: name, NodeType: NodeTypeBackend, TotalVRAM: 8_000_000_000, AvailableVRAM: 8_000_000_000}
+ Expect(registry.Register(ctx, node, true)).To(Succeed())
+ Expect(node.Status).To(Equal(StatusHealthy))
+ return node
+ }
+
+ statusOf := func(id string) string {
+ GinkgoHelper()
+ n, err := registry.Get(ctx, id)
+ Expect(err).ToNot(HaveOccurred())
+ return n.Status
+ }
+
+ // departTunnel gives a node a connection row, releases it, and ages the
+ // departure by `by` ON THE DATABASE CLOCK. A Go-side timestamp would be
+ // compared against the database's, which is the skew this window is written
+ // to be immune to.
+ departTunnel := func(nodeID string, by time.Duration) {
+ GinkgoHelper()
+ epoch, err := clusterR.Claim(ctx, nodeID, instance)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(clusterR.Release(ctx, nodeID, instance, epoch)).To(Succeed())
+ res := db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ by.Seconds(), nodeID)
+ Expect(res.Error).ToNot(HaveOccurred())
+ Expect(res.RowsAffected).To(Equal(int64(1)),
+ "precondition: the departure this spec ages must exist, or the spec proves nothing")
+ }
+
+ It("stops reporting a heartbeating node healthy once its tunnel has been gone past the grace", func() {
+ node := register("wedged-worker")
+ departTunnel(node.ID, grace+5*time.Second)
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusUnhealthy))
+ })
+
+ It("does not delete the node's model rows, because a demotion is enough to unwedge it", func() {
+ // Routing and eviction both select on status=healthy, so demoting stops
+ // the model being served from here and the next request places it
+ // somewhere reachable. Deleting rows would give any future defect in
+ // the presence read the largest blast radius in the system, for nothing
+ // the demotion does not already deliver.
+ node := register("wedged-with-models")
+ Expect(registry.SetNodeModel(ctx, node.ID, "llama", 0, "loaded", "127.0.0.1:50100", 0)).To(Succeed())
+ departTunnel(node.ID, grace+5*time.Second)
+
+ hm.doCheckAll(ctx)
+
+ models, err := registry.GetNodeModels(ctx, node.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(models).To(HaveLen(1))
+ })
+
+ It("does not re-promote a demoted node whose tunnel is still gone, however fresh its heartbeat", func() {
+ // The re-promotion branch used to fire on a fresh heartbeat alone, so
+ // the scheduler's demotion was undone on the next tick, every tick. Its
+ // cross-replica value was about one health interval.
+ node := register("still-gone")
+ departTunnel(node.ID, grace+5*time.Second)
+ Expect(registry.MarkUnhealthy(ctx, node.ID)).To(Succeed())
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusUnhealthy))
+ })
+
+ It("re-promotes a demoted node once its tunnel is back", func() {
+ // The negative control for the spec above: without this, refusing to
+ // re-promote ANYTHING would satisfy it.
+ node := register("recovered")
+ departTunnel(node.ID, grace+5*time.Second)
+ Expect(registry.MarkUnhealthy(ctx, node.ID)).To(Succeed())
+ _, err := clusterR.Claim(ctx, node.ID, instance)
+ Expect(err).ToNot(HaveOccurred())
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("leaves a node whose tunnel went inside the grace alone", func() {
+ // A worker re-dialling the load balancer right now. Demoting it is the
+ // fleet-wide eviction on a rolling frontend restart.
+ node := register("re-homing")
+ departTunnel(node.ID, grace/2)
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("leaves a node that has never dialled a tunnel alone", func() {
+ // No connection row at all, which is also every worker in the window
+ // between registering and dialling.
+ node := register("never-dialled")
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("leaves an AGENT node alone even when its tunnel would read as gone", func() {
+ // Agent workers hold no tunnel and still take their one verb over the
+ // bus. A departure row for one is not a fact about it.
+ node := &BackendNode{Name: "agent-worker", NodeType: NodeTypeAgent}
+ Expect(registry.Register(ctx, node, true)).To(Succeed())
+ departTunnel(node.ID, grace+5*time.Second)
+
+ hm.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("leaves every node alone when it has no presence reader", func() {
+ // A single-node deployment has nothing that can say a worker is gone.
+ node := register("no-cluster-registry")
+ departTunnel(node.ID, grace+5*time.Second)
+ plain := NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false, nil, 0)
+
+ plain.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ Expect(plain.ReadsAbsence()).To(BeFalse())
+ })
+
+ It("falls back to the documented grace when built with a presence reader and no grace", func() {
+ // Symmetric with the scheduler's default. A zero window here would make
+ // every departure a verdict the instant it was stamped, and every spec
+ // above passes an explicit grace, so nothing else reaches this.
+ node := register("no-grace")
+ stub := &stubPresence{answer: cluster.PresenceConnected}
+ defaulted := NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false, stub, 0)
+
+ defaulted.doCheckAll(ctx)
+
+ Expect(stub.nodes).To(ContainElement(node.ID))
+ Expect(stub.graces).To(ContainElement(config.DefaultWorkerReconnectGrace))
+ })
+
+ It("leaves a node alone when the presence query fails", func() {
+ // A database hiccup must not demote the fleet. Driven with a stub,
+ // because a real registry cannot be made to fail on demand.
+ node := register("query-fails")
+ broken := NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false,
+ &stubPresence{err: errors.New("connection reset")}, grace)
+
+ broken.doCheckAll(ctx)
+
+ Expect(statusOf(node.ID)).To(Equal(StatusHealthy))
+ })
+})
diff --git a/core/services/nodes/inflight.go b/core/services/nodes/inflight.go
index 3102a3254804..cc62782ceb42 100644
--- a/core/services/nodes/inflight.go
+++ b/core/services/nodes/inflight.go
@@ -27,13 +27,37 @@ import (
// interface therefore breaks this file's build (see the var assertion below)
// until it is wrapped with track() - so a new inference path can't be added
// without an in-flight accounting decision.
+// DO NOT "fix" this by embedding grpc.WrappedBackend, and do not delete the
+// nolint below. Both look like tidy-ups and both silently remove a guarantee.
+//
+// The ruleguard rule in hack/lint/ asks every decorator to embed
+// grpc.WrappedBackend, because that makes Unwrap structural. This is the one
+// decorator that must not, and the reason is the paragraph above: embedding
+// ControlBackend rather than Backend is exactly what forces every
+// InferenceBackend method to be declared and tracked here, on pain of a build
+// failure. grpc.WrappedBackend embeds the FULL Backend interface, so adopting
+// it would promote every inference method as untracked pass-through, the build
+// would stay green, and in-flight accounting would silently stop covering
+// whatever was added next.
+//
+// The transparency the rule exists to protect is still provided, explicitly:
+// the wrapped field, the Unwrap method and the grpc.BackendUnwrapper assertion
+// above. A spec drives it (see the wrapper transport specs), so removing them
+// reddens rather than merely regressing.
+//
+//nolint:gocritic // embeds ControlBackend deliberately; see the paragraph above before changing this
type InFlightTrackingClient struct {
grpc.ControlBackend // passthrough for control-plane / streaming-constructor methods
inner grpc.InferenceBackend // tracked inference methods delegate here
- registry InFlightTracker
- nodeID string
- modelName string
- replicaIndex int
+ // wrapped is the SAME object as ControlBackend and inner, kept at its full
+ // type so Unwrap can hand it back. The two fields above are deliberately
+ // narrowed to the sub-interfaces, which is what gives the compile-time
+ // guarantee below, and neither of them can be returned as a grpc.Backend.
+ wrapped grpc.Backend
+ registry InFlightTracker
+ nodeID string
+ modelName string
+ replicaIndex int
firstOnce sync.Once // guards onFirstComplete
onFirstComplete func() // called once after the first tracked inference call completes
@@ -44,11 +68,21 @@ type InFlightTrackingClient struct {
// InferenceBackend method is left unwrapped.
var _ grpc.Backend = (*InFlightTrackingClient)(nil)
+// And it must stay transparent to grpc.LastDialErrorOf. This is the wrapper
+// SmartRouter puts on every routed client, so a remote model's cached client is
+// one of these; without Unwrap, the transport guard in pkg/model reads nil for
+// every model the router produced and evicts on a tunnel blip.
+var _ grpc.BackendUnwrapper = (*InFlightTrackingClient)(nil)
+
+// Unwrap exposes the client this one decorates.
+func (c *InFlightTrackingClient) Unwrap() grpc.Backend { return c.wrapped }
+
// NewInFlightTrackingClient wraps a gRPC backend client with in-flight tracking.
func NewInFlightTrackingClient(inner grpc.Backend, registry InFlightTracker, nodeID, modelName string, replicaIndex int) *InFlightTrackingClient {
return &InFlightTrackingClient{
ControlBackend: inner,
inner: inner,
+ wrapped: inner,
registry: registry,
nodeID: nodeID,
modelName: modelName,
diff --git a/core/services/nodes/install_progress_publisher.go b/core/services/nodes/install_progress_publisher.go
index 60eacb711935..23bd67195faf 100644
--- a/core/services/nodes/install_progress_publisher.go
+++ b/core/services/nodes/install_progress_publisher.go
@@ -8,9 +8,13 @@ import (
)
// DebouncedInstallProgressPublisher buffers backend-install download ticks
-// and publishes them to the per-op NATS progress subject at most once per
-// `interval`. Always publishes the final event on Flush so the UI sees the
-// terminal percentage.
+// and hands them to its emit sink at most once per `interval`. Always emits the
+// final event on Flush so the UI sees the terminal percentage.
+//
+// The sink is a function rather than a NATS subject because the events now ride
+// the streaming HTTP response the worker serves over its tunnel. Leaving the
+// debounce here, rather than at the sink, is what keeps the ~4/s tick bound a
+// property of install progress itself and not of whatever carries it.
//
// Behavior: leading-edge debounce. The first OnDownload after a quiet window
// publishes immediately; subsequent ticks within `interval` only buffer the
@@ -18,13 +22,12 @@ import (
// keeps the wire chatter bounded (~4 events per second at 250ms) while
// still surfacing every meaningful percentage jump.
//
-// Lock ordering: never hold p.mu across a Publish call. Publish hits the
-// NATS client which may block on a slow link, and we don't want a stalled
-// network to stall the underlying gallery download loop.
+// Lock ordering: never hold p.mu across an emit call. The sink writes to the
+// network, which may block on a slow link, and we don't want a stalled network
+// to stall the underlying gallery download loop.
type DebouncedInstallProgressPublisher struct {
mu sync.Mutex
- client messaging.MessagingClient
- subject string
+ emit func(messaging.BackendInstallProgressEvent)
nodeID string
opID string
backend string
@@ -34,13 +37,20 @@ type DebouncedInstallProgressPublisher struct {
timer *time.Timer
}
-// NewDebouncedInstallProgressPublisher constructs a publisher for one
-// install operation. interval is the leading-edge debounce window
-// (~250ms in production).
-func NewDebouncedInstallProgressPublisher(client messaging.MessagingClient, nodeID, opID, backend string, interval time.Duration) *DebouncedInstallProgressPublisher {
+// NewDebouncedInstallProgressSink constructs a publisher for one install
+// operation that hands each debounced event to emit.
+//
+// emit is called with p.mu released, so a sink that blocks on a slow link
+// cannot stall the gallery download loop that feeds it.
+//
+// There was a sibling constructor that published to the per-op NATS progress
+// subject. It is gone rather than kept: a worker streams these events inside
+// the install response now, so the NATS publisher had no caller left, and a
+// constructor alive only for its own spec is a carrier a reader would believe
+// still runs.
+func NewDebouncedInstallProgressSink(emit func(messaging.BackendInstallProgressEvent), nodeID, opID, backend string, interval time.Duration) *DebouncedInstallProgressPublisher {
return &DebouncedInstallProgressPublisher{
- client: client,
- subject: messaging.SubjectNodeBackendInstallProgress(nodeID, opID),
+ emit: emit,
nodeID: nodeID,
opID: opID,
backend: backend,
@@ -70,7 +80,7 @@ func (p *DebouncedInstallProgressPublisher) OnDownload(file, current, total stri
p.lastPublishedAt = now
p.pending = nil
p.mu.Unlock()
- _ = p.client.Publish(p.subject, ev)
+ p.emit(ev)
return
}
// Within the window: buffer the latest event and arm a trailing
@@ -85,8 +95,8 @@ func (p *DebouncedInstallProgressPublisher) OnDownload(file, current, total stri
}
// flushPending is the trailing-edge publisher fired by the AfterFunc timer.
-// It clears the pending slot under the lock, then publishes outside the
-// lock so Publish never blocks an in-progress OnDownload call.
+// It clears the pending slot under the lock, then emits outside the lock so the
+// sink never blocks an in-progress OnDownload call.
func (p *DebouncedInstallProgressPublisher) flushPending() {
p.mu.Lock()
p.timer = nil
@@ -97,14 +107,14 @@ func (p *DebouncedInstallProgressPublisher) flushPending() {
}
p.mu.Unlock()
if pending != nil {
- _ = p.client.Publish(p.subject, *pending)
+ p.emit(*pending)
}
}
-// Flush publishes any pending buffered event synchronously and stops the
-// pending timer. Safe to call multiple times. Callers MUST defer Flush
-// after constructing the publisher so the terminal percentage reaches the
-// master even on error returns.
+// Flush emits any pending buffered event synchronously and stops the pending
+// timer. Safe to call multiple times. Callers MUST defer Flush after
+// constructing the publisher so the terminal percentage reaches the master even
+// on error returns.
func (p *DebouncedInstallProgressPublisher) Flush() {
p.mu.Lock()
if p.timer != nil {
@@ -115,6 +125,6 @@ func (p *DebouncedInstallProgressPublisher) Flush() {
p.pending = nil
p.mu.Unlock()
if pending != nil {
- _ = p.client.Publish(p.subject, *pending)
+ p.emit(*pending)
}
}
diff --git a/core/services/nodes/install_progress_publisher_test.go b/core/services/nodes/install_progress_publisher_test.go
index 04073cebeef0..a0c42c9fd479 100644
--- a/core/services/nodes/install_progress_publisher_test.go
+++ b/core/services/nodes/install_progress_publisher_test.go
@@ -1,6 +1,7 @@
package nodes
import (
+ "sync"
"time"
. "github.com/onsi/ginkgo/v2"
@@ -9,10 +10,29 @@ import (
"github.com/mudler/LocalAI/core/services/messaging"
)
+// collectingSink records every event the debouncer emits. Emits arrive from the
+// trailing timer's own goroutine as well as from OnDownload, so it locks.
+type collectingSink struct {
+ mu sync.Mutex
+ events []messaging.BackendInstallProgressEvent
+}
+
+func (c *collectingSink) emit(ev messaging.BackendInstallProgressEvent) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.events = append(c.events, ev)
+}
+
+func (c *collectingSink) snapshot() []messaging.BackendInstallProgressEvent {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return append([]messaging.BackendInstallProgressEvent(nil), c.events...)
+}
+
var _ = Describe("DebouncedInstallProgressPublisher", func() {
- It("publishes the first event immediately and debounces subsequent ones within the window", func() {
- mc := newScriptedMessagingClient()
- pub := NewDebouncedInstallProgressPublisher(mc, "n1", "op1", "vllm", 50*time.Millisecond)
+ It("emits the first event immediately and debounces subsequent ones within the window", func() {
+ sink := &collectingSink{}
+ pub := NewDebouncedInstallProgressSink(sink.emit, "n1", "op1", "vllm", 50*time.Millisecond)
// Three rapid-fire ticks within the debounce window.
pub.OnDownload("vllm.tar.zst", "100 MB", "1 GB", 10.0)
@@ -20,29 +40,44 @@ var _ = Describe("DebouncedInstallProgressPublisher", func() {
pub.OnDownload("vllm.tar.zst", "300 MB", "1 GB", 30.0)
pub.Flush()
- // First event publishes immediately; the others coalesce; Flush guarantees a final.
- // So we expect at least 2 publishes and at most 4 (lead + final + any window-bounded).
- Eventually(func() int {
- return len(mc.publishCalls(messaging.SubjectNodeBackendInstallProgress("n1", "op1")))
- }, "1s").Should(BeNumerically(">=", 2))
- calls := mc.publishCalls(messaging.SubjectNodeBackendInstallProgress("n1", "op1"))
- Expect(len(calls)).To(BeNumerically("<=", 4),
- "three ticks within the debounce window should produce at most ~4 publishes")
+ // First event emits immediately; the others coalesce; Flush guarantees a final.
+ // So we expect at least 2 emits and at most 4 (lead + final + any window-bounded).
+ Eventually(func() int { return len(sink.snapshot()) }, "1s").Should(BeNumerically(">=", 2))
+ Expect(len(sink.snapshot())).To(BeNumerically("<=", 4),
+ "three ticks within the debounce window should produce at most ~4 emits")
})
- It("publishes the final event after Flush with the latest percentage", func() {
- mc := newScriptedMessagingClient()
- pub := NewDebouncedInstallProgressPublisher(mc, "n1", "op1", "vllm", 50*time.Millisecond)
+ It("emits the final event after Flush with the latest percentage", func() {
+ sink := &collectingSink{}
+ pub := NewDebouncedInstallProgressSink(sink.emit, "n1", "op1", "vllm", 50*time.Millisecond)
pub.OnDownload("vllm.tar.zst", "1 GB", "1 GB", 100.0)
pub.Flush()
Eventually(func() float64 {
- calls := mc.publishCalls(messaging.SubjectNodeBackendInstallProgress("n1", "op1"))
- if len(calls) == 0 {
+ events := sink.snapshot()
+ if len(events) == 0 {
return -1
}
- return calls[len(calls)-1].Percentage
+ return events[len(events)-1].Percentage
}, "1s").Should(Equal(100.0))
})
+
+ It("stamps every event with the identity the frontend correlates on", func() {
+ // The op id and node id are what a frontend matches a progress line to
+ // an operation with; the subject used to carry them and now nothing
+ // else does, so the event body has to.
+ sink := &collectingSink{}
+ pub := NewDebouncedInstallProgressSink(sink.emit, "node-7", "op-42", "vllm", time.Millisecond)
+ pub.OnDownload("vllm.tar.zst", "1 GB", "1 GB", 100.0)
+ pub.Flush()
+
+ Eventually(func() int { return len(sink.snapshot()) }, "1s").Should(BeNumerically(">=", 1))
+ ev := sink.snapshot()[0]
+ Expect(ev.NodeID).To(Equal("node-7"))
+ Expect(ev.OpID).To(Equal("op-42"))
+ Expect(ev.Backend).To(Equal("vllm"))
+ Expect(ev.FileName).To(Equal("vllm.tar.zst"))
+ Expect(ev.Phase).To(Equal(messaging.PhaseDownloading))
+ })
})
diff --git a/core/services/nodes/interfaces.go b/core/services/nodes/interfaces.go
index be4dbc25d916..bce46dc335f4 100644
--- a/core/services/nodes/interfaces.go
+++ b/core/services/nodes/interfaces.go
@@ -2,8 +2,12 @@ package nodes
import (
"context"
+ "errors"
+ "fmt"
+ "net"
"time"
+ "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/LocalAI/core/services/messaging"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
@@ -97,10 +101,17 @@ type NodeHealthStore interface {
}
// ModelLocator is used by RemoteUnloaderAdapter for model discovery.
+//
+// Get is here for one reason: backend.stop is the only control verb whose
+// carrier depends on what KIND of worker it is addressed to, because agent
+// workers hold no tunnel and still take it over the bus. The callers that come
+// through NodeCommandSender carry a node id and nothing else, so the type is
+// read here rather than threaded through every one of them.
type ModelLocator interface {
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
+ Get(ctx context.Context, nodeID string) (*BackendNode, error)
}
// ModelLookup is used by DistributedModelStore for model existence queries.
@@ -137,20 +148,227 @@ type NodeManager interface {
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
}
-// BackendClientFactory creates gRPC backend clients.
+// WorkerDialerFor hands back the dial function for one worker's backend
+// processes: the shape grpc.WithContextDialer wants, bound to a node.
+//
+// A function type rather than a *cluster.WorkerDialer, so nothing here is bound
+// to that concrete type and a spec can supply a dial without building a tunnel
+// registry, a peer pool and a database. It is NOT to avoid a dependency: this
+// package already imports core/services/cluster (registry.go, for Migrate), and
+// an earlier version of this comment claimed otherwise. The dependency that
+// does matter runs the other way, and cluster is held to it by go list -deps.
+//
+// core/application supplies (*cluster.WorkerDialer).GRPCDialerFor, which has
+// exactly this shape.
+type WorkerDialerFor func(nodeID string) func(ctx context.Context, addr string) (net.Conn, error)
+
+// WorkerNetDialerFor hands back the dial function for one worker's own HTTP
+// server, in the shape http.Transport.DialContext and websocket.Dialer's
+// NetDialContext want. (*cluster.WorkerDialer).DialerFor bound to the http tag
+// has this shape.
+type WorkerNetDialerFor func(nodeID string) func(ctx context.Context, network, addr string) (net.Conn, error)
+
+// ErrWorkerUnroutable reports that this frontend could not get a request to a
+// worker's backend, and says NOTHING about whether that worker or its backend
+// is alive.
+//
+// It is the fifth condition, on this side of the package boundary. A worker's
+// presence is its HEARTBEAT, and this package owns that; a route to it is a
+// separate fact owned by core/services/cluster, and the two now differ. A
+// worker can be registered, heartbeating and serving every request another
+// replica sends it while being unroutable from here: it has not dialled its
+// tunnel yet after a frontend-first upgrade, the replica holding its tunnel is
+// restarting, the ownership row is a moment stale, this replica has no peer
+// mesh. Every one of those used to be indistinguishable from "the backend
+// process died", because gRPC reports both as codes.Unavailable.
+//
+// Everything in this package that DELETES a node_models row must consult it
+// first. That is the phase's stated catastrophe in its concrete form: a row
+// deleted here is a model reclaimed and reloaded elsewhere, so mistaking a peer
+// link blip for a dead backend evicts healthy work across the fleet at once.
+var ErrWorkerUnroutable = errors.New("nodes: this frontend has no route to that worker")
+
+// ErrNoWorkerDialer reports that something tried to reach a worker without a
+// way to reach it through the worker's tunnel.
+//
+// It is deliberately an ERROR and not a fallback to dialling the worker's
+// advertised address. A worker that holds a tunnel need not listen on anything
+// and may be behind NAT with no address to dial, so the fallback would work
+// only where the tunnel was not needed: on a single-host developer setup, and
+// nowhere the feature exists for.
+//
+// It is a SPECIALISATION of ErrWorkerUnroutable rather than a sibling, so the
+// single check every reaping path makes covers both. The difference between
+// them is only when they happen: this one is a boot-time misconfiguration, and
+// the general one is a running deployment losing a route for a moment. Neither
+// is a statement about the worker.
+var ErrNoWorkerDialer = fmt.Errorf("%w: no worker tunnel dialer is configured", ErrWorkerUnroutable)
+
+// unroutable reports why a call on client never reached the backend, or nil
+// when it did reach one.
+//
+// This is where core/services/cluster's five conditions cross the package
+// boundary. They cannot cross on the RPC error: gRPC turns any dialer failure
+// into codes.Unavailable with the cause flattened into a message, and
+// codes.Unavailable is ALSO what a backend process that has died produces.
+// pkg/grpc records the dialer's error VALUE instead, so cluster.ErrNoRoute and
+// whatever sits under it are still matchable here.
+//
+// A client that reports nothing (no custom dialer, or a test double) yields
+// nil, which means "the call reached a backend" and preserves the behaviour
+// every non-distributed caller has always had. Decorators are looked through;
+// see grpc.BackendUnwrapper for why that is not optional.
+//
+// A WORKER'S OWN REFUSAL also yields nil, and that is the second half of the
+// contract rather than a loophole. cluster.Dial keeps the three tunnelproto
+// sentinels out of the ErrNoRoute umbrella precisely so this function can tell
+// them apart, and for a whole phase nothing did: a backend process that crashed
+// on a healthy worker is no longer a dead listener's codes.Unavailable, it is
+// the worker refusing the stream with cluster.ErrStreamTargetUnavailable, which
+// gRPC then flattens into codes.Unavailable anyway. Reporting that as
+// unroutable made every reap path answer ProbeUnknown and leave the row, so the
+// replica slot never freed and (at the default MaxReplicasPerModel=1) the only
+// remaining cleanup was LRU eviction of HEALTHY models. A worker that answers
+// has demonstrated it is there, so the answer is evidence about its backend and
+// the reap guards may act on it.
+//
+// All three sentinels, not only the unavailable one, and the difference is
+// worth stating because two of them are not observations about the process. An
+// unknown tag means this worker does not serve gRPC streams at all; an invalid
+// request means the stored address is not a port in this worker's range.
+// Neither clears on its own, so a row that carries one is unreachable from
+// EVERY replica for as long as it exists, and reaping it converges: the model
+// is reloaded somewhere that works and re-registers a usable address. The
+// condition the phase refuses to reap on is a TRANSIENT one, and none of these
+// is transient.
+//
+// That last sentence is a claim about the WORKER, not about this file, and it
+// held only after the worker stopped answering a request frame that merely
+// arrived late with ErrStreamRequestInvalid. It did, and the frontend's half of
+// that contract is that a transient condition arrives as the fourth code:
+// cluster.ErrStreamNotServed is not in cluster.IsWorkerAnswer, so it reaches
+// here under the no-route umbrella and reaps nothing. If a worker ever starts
+// sending one of the three for something that clears on its own, this comment
+// becomes false and a live model gets evicted; the guard against that is at the
+// worker, in Tunnel.accept and classifyServiceFailure, and it is stated there.
+//
+// A reply code this frontend does not recognise is deliberately not in the set
+// either (see cluster.IsWorkerAnswer), so a newer worker's vocabulary reaches
+// an older frontend as "no route" and costs a retry rather than a row.
+// The control plane's sibling is nodes.controlFailure, which splits the same
+// two ways. That one checks the caller's remaining budget FIRST, because it
+// races a live deadline against a reply that may arrive in the same instant.
+// There is no such race here: this reads ONE error that was already recorded on
+// the client, and an expiry is not in streamRefusals, so it falls to the
+// umbrella below without a guard. Anyone changing the split must change both.
+func unroutable(client grpc.Backend) error {
+ // LastDialErrorOf and not a type assertion: the assertion could not see
+ // past a decorator, and SmartRouter hands every routed client out wrapped.
+ dialErr := grpc.LastDialErrorOf(client)
+ if dialErr == nil {
+ return nil
+ }
+ if cluster.IsWorkerAnswer(dialErr) {
+ return nil
+ }
+ // Multi-%w: the umbrella this package acts on, and the cluster condition
+ // underneath it, both stay matchable.
+ return fmt.Errorf("%w: %w", ErrWorkerUnroutable, dialErr)
+}
+
+// BackendClientFactory creates the gRPC clients this frontend uses to reach
+// model backends running on worker nodes.
+//
+// There is ONE method, and that is the design rather than an omission. A
+// direct-dial constructor alongside it would be reachable from every call site
+// that has an address, which is all of them, and the whole of this change is
+// that having an address is no longer enough to reach a backend. Callers that
+// genuinely want a raw address call pkg/grpc directly and are visible as such.
type BackendClientFactory interface {
- NewClient(address string, parallel bool) grpc.Backend
+ // NewClientForNode reaches a backend process running on a WORKER, through
+ // that worker's tunnel. address names WHICH process on the worker; it is
+ // not somewhere this process connects to.
+ //
+ // It returns an error rather than a client that falls back to a direct
+ // dial, so that a deployment with no tunnel dialer fails where the mistake
+ // is instead of quietly reopening the bypass.
+ NewClientForNode(nodeID, address string, parallel bool) (grpc.Backend, error)
}
-// tokenClientFactory is the default BackendClientFactory that creates gRPC
-// clients with an optional bearer token for distributed auth.
+// tokenClientFactory is the BackendClientFactory for a deployment with no
+// worker tunnel dialer, which is a misconfiguration rather than a mode. It
+// refuses every request, loudly, and reaches no worker.
+//
+// It exists so that the components that take a factory have something to hold
+// when none was wired, instead of a nil they would have to guard at every use.
type tokenClientFactory struct {
token string
}
-func (f *tokenClientFactory) NewClient(address string, parallel bool) grpc.Backend {
- if f.token != "" {
- return grpc.NewClientWithToken(address, parallel, nil, false, f.token)
+// NewClientForNode refuses. See ErrNoWorkerDialer for why this is not a direct
+// dial to address. The token this factory carries is the one a working dialer
+// would have used, kept only so the misconfiguration is repairable by wiring a
+// dialer rather than by also re-plumbing credentials.
+func (f *tokenClientFactory) NewClientForNode(nodeID, address string, _ bool) (grpc.Backend, error) {
+ return nil, fmt.Errorf("reaching backend %q on node %q: %w", address, nodeID, ErrNoWorkerDialer)
+}
+
+// tunnelClientFactory reaches a worker's backend processes through the worker's
+// tunnel, and is what every distributed deployment uses.
+type tunnelClientFactory struct {
+ token string
+ dialFor WorkerDialerFor
+}
+
+// NewTunnelClientFactory returns the factory that reaches worker backends
+// through dialFor. A nil dialFor is refused rather than degraded: this
+// constructor exists to close the direct-dial bypass, and one that silently
+// handed back a direct-dialling factory would reopen it for the whole process.
+func NewTunnelClientFactory(token string, dialFor WorkerDialerFor) (BackendClientFactory, error) {
+ if dialFor == nil {
+ return nil, fmt.Errorf("building the worker backend client factory: %w", ErrNoWorkerDialer)
+ }
+ return &tunnelClientFactory{token: token, dialFor: dialFor}, nil
+}
+
+func (f *tunnelClientFactory) NewClientForNode(nodeID, address string, parallel bool) (grpc.Backend, error) {
+ if nodeID == "" {
+ // Without a node there is no tunnel to pick, and the only thing left to
+ // do with the address would be to dial it.
+ return nil, fmt.Errorf("reaching backend %q: no node id: %w", address, ErrNoWorkerDialer)
+ }
+ dial := f.dialFor(nodeID)
+ if dial == nil {
+ return nil, fmt.Errorf("reaching backend %q on node %q: %w", address, nodeID, ErrNoWorkerDialer)
+ }
+ return grpc.NewClientWithDialer(address, parallel, nil, false, f.token, dial), nil
+}
+
+// unroutableHostSuffix is appended to a node id to build a Host for a worker
+// that reports no HTTP address.
+//
+// .invalid is reserved by RFC 2606 and resolves nowhere, which is the point:
+// the string exists ONLY to fill the host component of a URL, and a value that
+// could resolve would be one a future refactor could accidentally connect to.
+const unroutableHostSuffix = ".worker.invalid:80"
+
+// WorkerHTTPHost is the host to put in a URL addressed to a worker's own HTTP
+// server.
+//
+// A tunnel-only worker has no inbound address to report, and after this phase
+// it does not need one: the `http` stream tag ignores the target entirely and
+// the worker routes the stream to its own server wherever that bound. But an
+// http.Request still needs a host, so refusing an empty HTTPAddress would
+// refuse exactly the workers the tunnel exists for. This returns a name that
+// identifies the node for logs and for the Host header, and that nothing can
+// connect to.
+//
+// It is NOT a dial target and never becomes one. Every caller pairs it with a
+// transport whose DialContext is that node's tunnel, so the host is read and
+// discarded; see cluster.WorkerDialer.DialerFor and the `http` tag.
+func WorkerHTTPHost(nodeID, httpAddress string) string {
+ if httpAddress != "" {
+ return httpAddress
}
- return grpc.NewClient(address, parallel, nil, false)
+ return nodeID + unroutableHostSuffix
}
diff --git a/core/services/nodes/local_stub_invalidator_test.go b/core/services/nodes/local_stub_invalidator_test.go
index 00ed820dc6c2..444fefb6a9b2 100644
--- a/core/services/nodes/local_stub_invalidator_test.go
+++ b/core/services/nodes/local_stub_invalidator_test.go
@@ -44,7 +44,7 @@ var _ = Describe("LocalStubInvalidator", func() {
})
It("drops the local stub once the last replica of the model is gone", func() {
- store := NewDistributedModelStore(local, registry)
+ store := NewDistributedModelStore(local, registry, newFakeBackendClientFactory())
Expect(registry.SetNodeModel(context.Background(), nodeA.ID, "ghost-model", 0, "loaded", "10.0.0.1:12345", 0)).To(Succeed())
local.Set("ghost-model", model.NewModel("ghost-model", "10.0.0.1:12345", nil))
@@ -64,7 +64,7 @@ var _ = Describe("LocalStubInvalidator", func() {
})
It("keeps the local stub while another replica still serves the model", func() {
- store := NewDistributedModelStore(local, registry)
+ store := NewDistributedModelStore(local, registry, newFakeBackendClientFactory())
Expect(registry.SetNodeModel(context.Background(), nodeA.ID, "shared-model", 0, "loaded", "10.0.0.1:12345", 0)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), nodeB.ID, "shared-model", 0, "loaded", "10.0.0.2:12345", 0)).To(Succeed())
local.Set("shared-model", model.NewModel("shared-model", "10.0.0.1:12345", nil))
@@ -92,7 +92,7 @@ var _ = Describe("LocalStubInvalidator", func() {
})
It("drops the local stub when a whole node's replicas are removed", func() {
- store := NewDistributedModelStore(local, registry)
+ store := NewDistributedModelStore(local, registry, newFakeBackendClientFactory())
Expect(registry.SetNodeModel(context.Background(), nodeA.ID, "node-model", 0, "loaded", "10.0.0.1:12345", 0)).To(Succeed())
local.Set("node-model", model.NewModel("node-model", "10.0.0.1:12345", nil))
diff --git a/core/services/nodes/managers_agent_node_test.go b/core/services/nodes/managers_agent_node_test.go
index 8ee95c083f8e..5666c093d0f0 100644
--- a/core/services/nodes/managers_agent_node_test.go
+++ b/core/services/nodes/managers_agent_node_test.go
@@ -11,19 +11,20 @@ import (
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/testutil"
+ "github.com/mudler/LocalAI/core/services/workerctl"
)
-// Agent workers do not subscribe to the backend.* subjects, so asking one to
-// list its backends can only answer "no responders". ListBackends read that as
-// a node that had gone away and marked it unhealthy; the node's next heartbeat
-// marked it healthy again. Every poll of the backends view therefore flapped
-// every agent node in the cluster, and while it was unhealthy the router would
-// not schedule onto it.
+// Agent workers hold no tunnel and serve no control plane, so asking one to
+// list its backends can only fail. ListBackends read that failure as a node
+// that had gone away and marked it unhealthy; the node's next heartbeat marked
+// it healthy again. Every poll of the backends view therefore flapped every
+// agent node in the cluster, and while it was unhealthy the router would not
+// schedule onto it.
var _ = Describe("Backend listing across mixed node types", func() {
var (
db *gorm.DB
registry *NodeRegistry
- mc *scriptedMessagingClient
+ mc *scriptedControlWorkers
mgr *DistributedBackendManager
ctx context.Context
)
@@ -36,10 +37,10 @@ var _ = Describe("Backend listing across mixed node types", func() {
var err error
registry, err = NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
- mc = newScriptedMessagingClient()
+ mc = newScriptedControlWorkers()
mgr = &DistributedBackendManager{
local: stubLocalBackendManager{},
- adapter: NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute),
+ adapter: NewRemoteUnloaderAdapter(nil, nil, mc.controlClient(), 3*time.Minute, 15*time.Minute),
registry: registry,
}
ctx = context.Background()
@@ -62,23 +63,43 @@ var _ = Describe("Backend listing across mixed node types", func() {
It("leaves an agent node healthy instead of flapping it", func() {
agent := register("agent-worker-1", NodeTypeAgent)
- mc.scriptNoResponders(messaging.SubjectNodeBackendList(agent.ID))
+ mc.scriptUnroutable(agent.ID)
_, err := mgr.ListBackends()
Expect(err).ToNot(HaveOccurred())
Expect(statusOf(agent.ID)).To(Equal(StatusHealthy),
- "an agent node cannot answer backend.list and must not be judged on it")
+ "an agent node serves no control plane and must not be judged on it")
+ Expect(mc.callSubjects()).To(BeEmpty(),
+ "an agent node must not be asked a backend-worker verb at all")
})
- It("still marks a backend node unhealthy when it does not answer", func() {
+ // The direction changed with the carrier, and the change is the safety
+ // property rather than a regression. "No responders" meant the worker was
+ // not on the bus; a failed control RPC means THIS frontend could not route
+ // to it, which is equally what a healthy worker re-homing its tunnel
+ // between frontend replicas produces. Demoting on that is the fleet-wide
+ // eviction this phase exists to prevent, so a node that does not answer is
+ // skipped and left alone. cluster.Presence, read identically on every
+ // replica from the database, is what may say a worker is gone.
+ It("leaves a BACKEND node healthy when its control RPC could not be routed", func() {
backendNode := register("worker-a", NodeTypeBackend)
- mc.scriptNoResponders(messaging.SubjectNodeBackendList(backendNode.ID))
+ mc.scriptUnroutable(backendNode.ID)
_, err := mgr.ListBackends()
Expect(err).ToNot(HaveOccurred())
- Expect(statusOf(backendNode.ID)).To(Equal(StatusUnhealthy),
- "a backend worker that does not answer is genuinely gone")
+ Expect(statusOf(backendNode.ID)).To(Equal(StatusHealthy),
+ "a route this frontend could not open is not evidence the worker has gone")
+ })
+
+ It("still reports the backends of a node that does answer", func() {
+ backendNode := register("worker-b", NodeTypeBackend)
+ mc.scriptReply(controlKey(backendNode.ID, workerctl.PathBackendList),
+ messaging.BackendListReply{Backends: []messaging.NodeBackendInfo{{Name: "vllm"}}})
+
+ backends, err := mgr.ListBackends()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(backends).To(HaveKey("vllm"))
})
})
diff --git a/core/services/nodes/managers_distributed.go b/core/services/nodes/managers_distributed.go
index 4132eca797db..c488d16e19a3 100644
--- a/core/services/nodes/managers_distributed.go
+++ b/core/services/nodes/managers_distributed.go
@@ -14,11 +14,11 @@ import (
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
- "github.com/nats-io/nats.go"
)
-// DistributedModelManager wraps a local ModelManager and adds NATS fan-out
-// for model deletion so worker nodes clean up stale files.
+// DistributedModelManager wraps a local ModelManager and fans model deletion
+// out to the worker nodes so they clean up stale files. The fan-out is a
+// control RPC over each worker's tunnel; see RemoteUnloaderAdapter.
type DistributedModelManager struct {
local galleryop.ModelManager
adapter *RemoteUnloaderAdapter
@@ -56,8 +56,9 @@ type nodeProgressSink interface {
UpdateNodeProgress(opID, nodeID string, np galleryop.NodeProgress)
}
-// DistributedBackendManager wraps a local BackendManager and adds NATS fan-out
-// for backend deletion so worker nodes clean up stale files.
+// DistributedBackendManager wraps a local BackendManager and fans backend
+// deletion out to the worker nodes so they clean up stale files. The fan-out is
+// a control RPC over each worker's tunnel; see RemoteUnloaderAdapter.
type DistributedBackendManager struct {
local galleryop.BackendManager
adapter *RemoteUnloaderAdapter
@@ -122,7 +123,7 @@ func (r BackendOpResult) Err() error {
// nodes get an immediate attempt; success deletes the row, failure records
// the error and leaves the row for the reconciler to retry.
//
-// `apply` is the NATS round-trip for one node. Returning an error keeps the
+// `apply` is the control RPC for one node. Returning an error keeps the
// row in the queue and marks the per-node status as "error"; returning nil
// deletes the row and reports "success". For non-healthy nodes the status
// is "queued" — no attempt is made right now, reconciler will pick it up
@@ -168,7 +169,7 @@ func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context
continue
}
// Backend lifecycle ops only make sense on backend-type workers.
- // Agent workers don't subscribe to backend.install/delete/list, so
+ // Agent workers hold no tunnel and serve no control plane, so
// enqueueing for them guarantees a forever-retrying row that the
// reconciler can never drain. Silently skip - they aren't consumers.
if node.NodeType != "" && node.NodeType != NodeTypeBackend {
@@ -213,8 +214,7 @@ func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context
continue
}
- // Record failure for backoff. If it's an ErrNoResponders, the node's
- // gone AWOL - mark unhealthy so the router stops picking it too.
+ // Record failure for backoff.
errMsg := applyErr.Error()
// Worker-still-installing is a "soft" failure: the worker is most
@@ -234,10 +234,14 @@ func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context
continue
}
- if errors.Is(applyErr, nats.ErrNoResponders) {
- xlog.Warn("No NATS responders for node, marking unhealthy", "node", node.Name, "nodeID", node.ID)
- d.registry.MarkUnhealthy(ctx, node.ID)
- }
+ // A failed control RPC does not demote the node, and that is the point
+ // rather than an omission. The control plane's failures mean "this
+ // frontend could not route to it", which is equally true of a worker
+ // that is heartbeating, serving another replica and re-homing its
+ // tunnel. Demoting on that is the fleet-wide eviction this phase exists
+ // to prevent. Absence is a separate fact, read from the database
+ // identically on every replica; the scheduler reads it through
+ // cluster.Presence and nothing on this path does.
if id, err := d.findPendingRow(ctx, node.ID, backend, op); err == nil {
_ = d.registry.RecordPendingBackendOpFailure(ctx, id, errMsg)
}
@@ -331,9 +335,9 @@ func (d *DistributedBackendManager) DeleteBackendDetailed(ctx context.Context, n
// populated from the first node seen so single-node-minded callers still work.
//
// Pending/offline/draining nodes are skipped because they aren't expected to
-// answer NATS requests, and so are non-backend workers, which do not subscribe
-// to backend.list at all; unhealthy backend nodes are still queried —
-// ErrNoResponders then marks them unhealthy and the loop continues.
+// answer, and so are non-backend workers, which serve no control plane at all;
+// unhealthy backend nodes are still queried, and a node that does not answer is
+// skipped rather than demoted.
func (d *DistributedBackendManager) ListBackends() (gallery.SystemBackends, error) {
result := make(gallery.SystemBackends)
allNodes, err := d.registry.List(context.Background())
@@ -345,9 +349,9 @@ func (d *DistributedBackendManager) ListBackends() (gallery.SystemBackends, erro
if node.Status == StatusPending || node.Status == StatusOffline || node.Status == StatusDraining {
continue
}
- // Only backend workers subscribe to backend.list. Asking an agent
- // worker can only answer "no responders", which the error handling
- // below reads as a node that has gone away, so every poll of this view
+ // Only backend workers serve backend.list. An agent worker holds no
+ // tunnel, so asking one can only fail, and the failure handling used to
+ // read that as a node that had gone away: every poll of this view
// marked every agent node unhealthy and its next heartbeat marked it
// healthy again. The backend-op fan-out skips them for the same reason.
if node.NodeType != "" && node.NodeType != NodeTypeBackend {
@@ -355,11 +359,9 @@ func (d *DistributedBackendManager) ListBackends() (gallery.SystemBackends, erro
}
reply, err := d.adapter.ListBackends(node.ID)
if err != nil {
- if errors.Is(err, nats.ErrNoResponders) {
- xlog.Warn("No NATS responders for node, marking unhealthy", "node", node.Name, "nodeID", node.ID)
- d.registry.MarkUnhealthy(context.Background(), node.ID)
- continue
- }
+ // Skipped, never demoted. Listing a node's backends is a read, and
+ // a read this frontend could not route says nothing about whether
+ // the worker is there; see the fan-out above for the same rule.
xlog.Warn("Failed to list backends on worker", "node", node.Name, "error", err)
continue
}
@@ -453,7 +455,7 @@ func (d *DistributedBackendManager) clearSatisfiedInstallRows(ctx context.Contex
// InstallBackend fans out installation through the pending-ops queue so
// non-healthy nodes get retried when they come back instead of being silently
-// skipped. Reply success from the NATS round-trip deletes the queue row;
+// skipped. Reply success from the control RPC deletes the queue row;
// reply.Success==false is treated as an error so the row stays for retry.
//
// When op.TargetNodeID is set, only that node is visited - the same allowlist
@@ -493,9 +495,10 @@ func (d *DistributedBackendManager) InstallBackend(ctx context.Context, op *gall
})
}
}
- // nil-callback shortcut: when there is nothing to deliver to,
- // hand the adapter a nil onProgress so it skips the per-op NATS
- // subscription. Matches the pre-Phase-4 bridgeProgressCb semantics.
+ // nil-callback shortcut: when there is nothing to deliver to, hand the
+ // adapter a nil onProgress so it discards the worker's progress lines
+ // instead of decoding them. They ride the install response itself, so
+ // there is nothing to arrange either way.
var onProgressArg func(messaging.BackendInstallProgressEvent)
if progressCb != nil || d.progressSink != nil {
onProgressArg = onProgress
@@ -530,7 +533,7 @@ func (d *DistributedBackendManager) InstallBackend(ctx context.Context, op *gall
return nil
}
-// UpgradeBackend uses a separate NATS subject (backend.upgrade) so the slow
+// UpgradeBackend uses a separate control verb (backend.upgrade) so the slow
// force-reinstall path doesn't head-of-line-block routine model loads on
// the same worker. Only nodes that already report this backend as installed
// are targeted — fanning out to every node would ask workers to "upgrade"
@@ -538,7 +541,7 @@ func (d *DistributedBackendManager) InstallBackend(ctx context.Context, op *gall
// worker has no platform variant for a linux-only backend) and leaves a
// forever-retrying pending_backend_ops row.
//
-// Rolling-update fallback: when a worker returns nats.ErrNoResponders on
+// Rolling-update fallback: when a worker answers that it does not serve
// backend.upgrade, we try the legacy backend.install Force=true path so a
// new master + old worker still converges. Drop the fallback once every
// worker in the fleet is on 2026-05-08 or newer.
@@ -600,8 +603,11 @@ func (d *DistributedBackendManager) UpgradeBackend(ctx context.Context, op *gall
reply, err := d.adapter.UpgradeBackend(node.ID, name, string(galleriesJSON), "", "", "", 0, opID, onProgressArg)
if err != nil {
// Rolling-update fallback: an older worker doesn't know
- // backend.upgrade. Try the legacy install-with-force path.
- if errors.Is(err, nats.ErrNoResponders) {
+ // backend.upgrade and answers 404 for it. ONLY that answer
+ // triggers the fallback: a worker this frontend merely could not
+ // route to has said nothing, and re-firing a force-reinstall at it
+ // would turn a lost route into a destructive retry.
+ if errors.Is(err, ErrWorkerControlUnsupported) {
instReply, instErr := d.adapter.installWithForceFallback(node.ID, name, string(galleriesJSON), "", "", "", 0, opID, onProgressArg)
if instErr != nil {
return instErr
@@ -625,8 +631,10 @@ func (d *DistributedBackendManager) UpgradeBackend(ctx context.Context, op *gall
return hardErr
}
// Same in-progress surfacing as InstallBackend: a long-running worker
- // upgrade that timed out the NATS round-trip must not be reported as
- // green success.
+ // upgrade that outlived the caller's budget must not be reported as green
+ // success. Pinned by "reports an upgrade that ran out of budget as still
+ // installing", because this is the second of the rule's two call sites and
+ // dropping it here left the suite green.
for _, n := range result.Nodes {
if n.Status == galleryop.NodeStatusRunningOnWorker {
return fmt.Errorf("%w: %s", galleryop.ErrWorkerStillInstalling, summarizeRunningOnWorker(result.Nodes))
diff --git a/core/services/nodes/managers_distributed_test.go b/core/services/nodes/managers_distributed_test.go
index b83200eeb1a3..68b8b642006c 100644
--- a/core/services/nodes/managers_distributed_test.go
+++ b/core/services/nodes/managers_distributed_test.go
@@ -2,13 +2,11 @@ package nodes
import (
"context"
- "encoding/json"
"errors"
"runtime"
"sync"
"time"
- "github.com/nats-io/nats.go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gorm.io/gorm"
@@ -18,245 +16,9 @@ import (
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/testutil"
+ "github.com/mudler/LocalAI/core/services/workerctl"
)
-// scriptedMessagingClient maps a NATS subject to a canned reply payload
-// (or error). Used so each fan-out request can simulate a different worker
-// outcome without spinning up real NATS.
-type scriptedMessagingClient struct {
- mu sync.Mutex
- replies map[string][]byte
- errs map[string]error
- calls []requestCall
- matchedReplies map[string][]matchedReply
- publishes []progressPublishCall
- scheduledProgressPublishes []scheduledProgressPublish
- subscribes []string
-}
-
-// progressPublishCall records a single Publish invocation. The progress
-// publisher tests assert on the sequence of BackendInstallProgressEvent
-// values written to a per-op subject, so we capture both subject and the
-// decoded event. Named to avoid clashing with the simpler `publishCall`
-// already defined in unloader_test.go (which stores raw JSON bytes for
-// non-progress assertions).
-type progressPublishCall struct {
- Subject string
- Event messaging.BackendInstallProgressEvent
-}
-
-// scheduledProgressPublish queues a batch of BackendInstallProgressEvent
-// values to be delivered the next time Subscribe is called with the matching
-// subject. This lets master-side tests assert that the adapter installs its
-// handler BEFORE publishing the install request, by scripting events to be
-// delivered as soon as the subscription appears.
-type scheduledProgressPublish struct {
- subject string
- events []messaging.BackendInstallProgressEvent
-}
-
-// matchedReply lets a test script a canned reply that only fires when the
-// inbound request matches a predicate. Used by scriptReplyMatching to
-// distinguish "install Force=true" (the fallback) from "install Force=false"
-// on the same subject.
-type matchedReply struct {
- pred func(messaging.BackendInstallRequest) bool
- reply []byte
- fallback []byte
- fallbackErr error
-}
-
-func newScriptedMessagingClient() *scriptedMessagingClient {
- return &scriptedMessagingClient{
- replies: map[string][]byte{},
- errs: map[string]error{},
- }
-}
-
-func (s *scriptedMessagingClient) scriptReply(subject string, reply any) {
- raw, err := json.Marshal(reply)
- Expect(err).ToNot(HaveOccurred())
- s.mu.Lock()
- defer s.mu.Unlock()
- s.replies[subject] = raw
-}
-
-func (s *scriptedMessagingClient) scriptErr(subject string, err error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.errs[subject] = err
-}
-
-// scriptNoResponders scripts a nats.ErrNoResponders error for `subject` so
-// tests can simulate "old worker without backend.upgrade subscription"
-// scenarios. Uses the real nats sentinel so errors.Is(...) works at the
-// caller (the manager's NoResponders fallback path).
-func (s *scriptedMessagingClient) scriptNoResponders(subject string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.errs[subject] = nats.ErrNoResponders
-}
-
-// scriptReplyMatching is like scriptReply but the canned reply only fires
-// when the inbound request payload matches `pred(req)`. Lets tests
-// differentiate "install with Force=true" from "install Force=false" on
-// the same subject — useful for asserting the rolling-update fallback
-// path actually sets Force=true on its retry.
-//
-// If `pred` returns false (or the unmarshal of the payload into the
-// predicate's expected type fails), the subject falls through to whatever
-// was scripted before (or to the unscripted default ErrNoResponders).
-func (s *scriptedMessagingClient) scriptReplyMatching(subject string, pred func(messaging.BackendInstallRequest) bool, reply messaging.BackendInstallReply) {
- raw, err := json.Marshal(reply)
- Expect(err).ToNot(HaveOccurred())
- s.mu.Lock()
- defer s.mu.Unlock()
- prev := s.replies[subject] // may be nil — that's fine
- prevErr := s.errs[subject] // may be nil — that's fine
- if s.matchedReplies == nil {
- s.matchedReplies = map[string][]matchedReply{}
- }
- s.matchedReplies[subject] = append(s.matchedReplies[subject], matchedReply{
- pred: pred,
- reply: raw,
- fallback: prev,
- fallbackErr: prevErr,
- })
-}
-
-func (s *scriptedMessagingClient) Request(subject string, data []byte, timeout time.Duration) ([]byte, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.calls = append(s.calls, requestCall{Subject: subject, Data: data, Timeout: timeout})
-
- // Predicate-matched replies take precedence over flat scriptReply.
- if matchers, ok := s.matchedReplies[subject]; ok {
- var req messaging.BackendInstallRequest
- _ = json.Unmarshal(data, &req)
- for _, m := range matchers {
- if m.pred(req) {
- return m.reply, nil
- }
- }
- // No predicate matched — fall through to the recorded fallback
- // (whatever was scripted before scriptReplyMatching took over).
- if matchers[0].fallback != nil {
- return matchers[0].fallback, nil
- }
- if matchers[0].fallbackErr != nil {
- return nil, matchers[0].fallbackErr
- }
- // No fallback either — default to ErrNoResponders.
- return nil, nats.ErrNoResponders
- }
-
- if err, ok := s.errs[subject]; ok && err != nil {
- return nil, err
- }
- if reply, ok := s.replies[subject]; ok {
- return reply, nil
- }
- // Simulate ErrNoResponders for any unscripted subject so tests fail
- // loudly when they forget to script a node.
- return nil, &fakeNoRespondersErr{}
-}
-
-// Publish records each call so progress-publisher tests can assert on the
-// stream of events written to a subject. The real messaging.Client JSON
-// encodes the payload before sending, but our publisher hands a typed
-// struct directly, so we handle both shapes.
-func (s *scriptedMessagingClient) Publish(subject string, data any) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- switch ev := data.(type) {
- case messaging.BackendInstallProgressEvent:
- s.publishes = append(s.publishes, progressPublishCall{Subject: subject, Event: ev})
- case []byte:
- var e messaging.BackendInstallProgressEvent
- _ = json.Unmarshal(ev, &e)
- s.publishes = append(s.publishes, progressPublishCall{Subject: subject, Event: e})
- }
- return nil
-}
-
-// publishCalls returns every BackendInstallProgressEvent that was published
-// to `subject`, in order. Lets tests assert on debounce behavior without
-// depending on internal Publish timing.
-func (s *scriptedMessagingClient) publishCalls(subject string) []messaging.BackendInstallProgressEvent {
- s.mu.Lock()
- defer s.mu.Unlock()
- out := make([]messaging.BackendInstallProgressEvent, 0)
- for _, c := range s.publishes {
- if c.Subject != subject {
- continue
- }
- out = append(out, c.Event)
- }
- return out
-}
-
-// scheduleProgressPublish queues a set of BackendInstallProgressEvent values
-// to be delivered on the next Subscribe call matching the per-op progress
-// subject. A short delay before delivery gives the subscriber time to install
-// its message handler before the events arrive.
-func (s *scriptedMessagingClient) scheduleProgressPublish(nodeID, opID string, events []messaging.BackendInstallProgressEvent) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.scheduledProgressPublishes = append(s.scheduledProgressPublishes, scheduledProgressPublish{
- subject: messaging.SubjectNodeBackendInstallProgress(nodeID, opID),
- events: events,
- })
-}
-
-// subscribeCalls returns the subjects on which Subscribe was invoked.
-// Used to confirm the master skipped subscription when onProgress was nil.
-func (s *scriptedMessagingClient) subscribeCalls() []string {
- s.mu.Lock()
- defer s.mu.Unlock()
- out := make([]string, len(s.subscribes))
- copy(out, s.subscribes)
- return out
-}
-
-func (s *scriptedMessagingClient) Subscribe(subject string, handler func([]byte)) (messaging.Subscription, error) {
- s.mu.Lock()
- s.subscribes = append(s.subscribes, subject)
- matched := []scheduledProgressPublish{}
- remaining := s.scheduledProgressPublishes[:0]
- for _, sp := range s.scheduledProgressPublishes {
- if sp.subject == subject {
- matched = append(matched, sp)
- } else {
- remaining = append(remaining, sp)
- }
- }
- s.scheduledProgressPublishes = remaining
- s.mu.Unlock()
-
- go func() {
- time.Sleep(20 * time.Millisecond)
- for _, sp := range matched {
- for _, ev := range sp.events {
- raw, _ := json.Marshal(ev)
- handler(raw)
- }
- }
- }()
-
- return &fakeSubscription{}, nil
-}
-func (s *scriptedMessagingClient) QueueSubscribe(_ string, _ string, _ func([]byte)) (messaging.Subscription, error) {
- return &fakeSubscription{}, nil
-}
-func (s *scriptedMessagingClient) QueueSubscribeReply(_ string, _ string, _ func([]byte, func([]byte))) (messaging.Subscription, error) {
- return &fakeSubscription{}, nil
-}
-func (s *scriptedMessagingClient) SubscribeReply(_ string, _ func([]byte, func([]byte))) (messaging.Subscription, error) {
- return &fakeSubscription{}, nil
-}
-func (s *scriptedMessagingClient) IsConnected() bool { return true }
-func (s *scriptedMessagingClient) Close() {}
-
// recordingNodeCall captures a single UpdateNodeProgress invocation so
// per-node OpStatus tests can assert on the sequence of writes the
// DistributedBackendManager fans out into the sink.
@@ -292,16 +54,6 @@ func (r *recordingProgressSink) callsFor(opID, nodeID string) []galleryop.NodePr
return out
}
-// fakeNoRespondersErr is the unscripted-subject default. It matches
-// nats.ErrNoResponders by string only - used when a test forgets to script
-// a node so the failure is loud but doesn't tickle errors.Is(...) sentinel
-// paths the test wasn't deliberately exercising. Tests that DO want the
-// real sentinel (e.g. to drive the manager's NoResponders fallback) call
-// scriptNoResponders instead, which scripts nats.ErrNoResponders directly.
-type fakeNoRespondersErr struct{}
-
-func (e *fakeNoRespondersErr) Error() string { return "no responders" }
-
// stubLocalBackendManager satisfies galleryop.BackendManager for the
// distributed manager's `local` field. The DeleteBackend path expects to
// call into local first; in distributed mode the frontend rarely has
@@ -329,7 +81,7 @@ var _ = Describe("DistributedBackendManager", func() {
var (
db *gorm.DB
registry *NodeRegistry
- mc *scriptedMessagingClient
+ mc *scriptedControlWorkers
adapter *RemoteUnloaderAdapter
mgr *DistributedBackendManager
ctx context.Context
@@ -344,8 +96,8 @@ var _ = Describe("DistributedBackendManager", func() {
registry, err = NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
- mc = newScriptedMessagingClient()
- adapter = NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute)
+ mc = newScriptedControlWorkers()
+ adapter = NewRemoteUnloaderAdapter(nil, nil, mc.controlClient(), 3*time.Minute, 15*time.Minute)
mgr = &DistributedBackendManager{
local: stubLocalBackendManager{},
adapter: adapter,
@@ -385,10 +137,10 @@ var _ = Describe("DistributedBackendManager", func() {
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(n1.ID),
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
- mc.scriptReply(messaging.SubjectNodeBackendInstall(n2.ID),
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.2:50100"})
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
+ mc.scriptReply(controlKey(n2.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.2:50100"})
Expect(mgr.InstallBackend(ctx, op("vllm-development"), nil)).To(Succeed())
})
@@ -399,9 +151,9 @@ var _ = Describe("DistributedBackendManager", func() {
n1 := registerHealthyBackend("dgx-casa", "10.0.0.1:50051")
n2 := registerHealthyBackend("nvidia-thor", "10.0.0.2:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendInstall),
messaging.BackendInstallReply{Success: false, Error: "no child with platform linux/arm64 in index quay.io/...master-cpu-vllm"})
- mc.scriptReply(messaging.SubjectNodeBackendInstall(n2.ID),
+ mc.scriptReply(controlKey(n2.ID, workerctl.PathBackendInstall),
messaging.BackendInstallReply{Success: false, Error: "disk full"})
err := mgr.InstallBackend(ctx, op("vllm-development"), nil)
@@ -419,9 +171,9 @@ var _ = Describe("DistributedBackendManager", func() {
ok := registerHealthyBackend("worker-ok", "10.0.0.1:50051")
bad := registerHealthyBackend("worker-bad", "10.0.0.2:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(ok.ID),
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
- mc.scriptReply(messaging.SubjectNodeBackendInstall(bad.ID),
+ mc.scriptReply(controlKey(ok.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
+ mc.scriptReply(controlKey(bad.ID, workerctl.PathBackendInstall),
messaging.BackendInstallReply{Success: false, Error: "out of memory"})
err := mgr.InstallBackend(ctx, op("vllm-development"), nil)
@@ -437,8 +189,8 @@ var _ = Describe("DistributedBackendManager", func() {
registerUnhealthyBackend("worker-a", "10.0.0.1:50051")
registerUnhealthyBackend("worker-b", "10.0.0.2:50051")
- // No replies scripted: if the manager tried to call Request,
- // it would hit the "no responders" default and we'd see it.
+ // No replies scripted: if the manager issued a control RPC at
+ // all, the unscripted-verb default answers 500 and we'd see it.
Expect(mgr.InstallBackend(ctx, op("vllm-development"), nil)).To(Succeed())
mc.mu.Lock()
calls := len(mc.calls)
@@ -458,11 +210,10 @@ var _ = Describe("DistributedBackendManager", func() {
target := registerHealthyBackend("worker-target", "10.0.0.1:50051")
other := registerHealthyBackend("worker-other", "10.0.0.2:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(target.ID),
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
- // No reply scripted for `other`: if InstallBackend fans out
- // to it, the fakeNoRespondersErr default would surface and
- // the test would fail.
+ mc.scriptReply(controlKey(target.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
+ // No reply scripted for `other`: if InstallBackend fans out to
+ // it, the unscripted-verb default surfaces and the test fails.
targetedOp := &galleryop.ManagementOp[gallery.GalleryBackend, any]{
GalleryElementName: "llama-cpp",
@@ -473,13 +224,13 @@ var _ = Describe("DistributedBackendManager", func() {
mc.mu.Lock()
defer mc.mu.Unlock()
Expect(mc.calls).To(HaveLen(1))
- Expect(mc.calls[0].Subject).To(Equal(messaging.SubjectNodeBackendInstall(target.ID)))
- Expect(mc.calls[0].Subject).ToNot(Equal(messaging.SubjectNodeBackendInstall(other.ID)))
+ Expect(mc.calls[0].Subject).To(Equal(controlKey(target.ID, workerctl.PathBackendInstall)))
+ Expect(mc.calls[0].Subject).ToNot(Equal(controlKey(other.ID, workerctl.PathBackendInstall)))
})
})
Context("when op.TargetNodeID is set to a node that does not exist", func() {
- It("returns nil without sending any NATS request", func() {
+ It("returns nil without sending any control request", func() {
registerHealthyBackend("worker-a", "10.0.0.1:50051")
ghostOp := &galleryop.ManagementOp[gallery.GalleryBackend, any]{
@@ -498,10 +249,10 @@ var _ = Describe("DistributedBackendManager", func() {
It("returns galleryop.ErrWorkerStillInstalling and keeps the queue row with NextRetryAt pushed out", func() {
n := registerHealthyBackend("slow", "10.0.0.1:50051")
- // Script a NATS timeout on the install subject. The adapter
- // wraps this into galleryop.ErrWorkerStillInstalling, which
- // the manager should treat as a soft failure.
- mc.scriptErr(messaging.SubjectNodeBackendInstall(n.ID), nats.ErrTimeout)
+ // The install RPC ends with the caller's budget spent. The
+ // adapter wraps that into galleryop.ErrWorkerStillInstalling,
+ // which the manager treats as a soft failure.
+ mc.scriptTimeout(n.ID)
err := mgr.InstallBackend(ctx, op("vllm"), nil)
Expect(err).To(HaveOccurred())
@@ -513,7 +264,8 @@ var _ = Describe("DistributedBackendManager", func() {
Expect(rows).To(HaveLen(1))
Expect(rows[0].Backend).To(Equal("vllm"))
// The adapter is configured with a 3m install timeout in this
- // suite (NewRemoteUnloaderAdapter above). NextRetryAt should
+ // suite (NewRemoteUnloaderAdapter above), and the RPC failed
+ // instantly rather than waiting it out. NextRetryAt should
// be ~now+3m; a > now+2m bound is safe-but-tight enough to
// catch the buggy short default (30s exponential backoff).
Expect(rows[0].NextRetryAt).To(BeTemporally(">", time.Now().Add(2*time.Minute)),
@@ -521,17 +273,17 @@ var _ = Describe("DistributedBackendManager", func() {
})
})
- Context("end-to-end: timeout then successful reconcile via backend.list", func() {
+ Context("end-to-end: budget spent, then successful reconcile via backend.list", func() {
It("surfaces the install in ListBackends after the worker finishes", func() {
// Use the same node-registration helper the Task 5 test uses
// so the test fixture is identical to the prior context.
node := registerHealthyBackend("jetson", "10.0.0.2:50051")
- // First install attempt: NATS times out. The adapter wraps
+ // First install attempt: the budget runs out. The adapter wraps
// this as galleryop.ErrWorkerStillInstalling and the manager
// keeps the pending_backend_ops row alive with NextRetryAt
// pushed out (asserted in the previous context).
- mc.scriptErr(messaging.SubjectNodeBackendInstall(node.ID), nats.ErrTimeout)
+ mc.scriptTimeout(node.ID)
err := mgr.InstallBackend(ctx, op("vllm"), nil)
Expect(err).To(HaveOccurred())
@@ -542,10 +294,11 @@ var _ = Describe("DistributedBackendManager", func() {
Expect(listErr).ToNot(HaveOccurred())
Expect(rows).To(HaveLen(1))
- // The worker finished installing in the background. Script
- // backend.list on the same scriptedMessagingClient so the
- // manager's ListBackends fan-out reports the backend.
- mc.scriptReply(messaging.SubjectNodeBackendList(node.ID), messaging.BackendListReply{
+ // The worker finished installing in the background and answers
+ // again, so the manager's ListBackends fan-out reports the
+ // backend.
+ mc.clearTimeout(node.ID)
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendList), messaging.BackendListReply{
Backends: []messaging.NodeBackendInfo{{Name: "vllm"}},
})
@@ -565,13 +318,74 @@ var _ = Describe("DistributedBackendManager", func() {
})
})
+ // The agent skip is stated at TWO call sites, the fan-out and
+ // ListBackends, and was pinned only at ListBackends. Agent workers
+ // serve no control plane, so a row enqueued for one can never be
+ // drained: it retries every reconciler tick until the dead-letter cap
+ // and shows in the UI as an operation that never finishes.
+ It("enqueues nothing for an agent node, which serves no control plane", func() {
+ backend := registerHealthyBackend("worker-a", "10.0.0.1:50051")
+ agent := &BackendNode{Name: "agent-a", NodeType: NodeTypeAgent, Address: "10.0.0.2:50051"}
+ Expect(registry.Register(ctx, agent, true)).To(Succeed())
+
+ mc.scriptReply(controlKey(backend.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
+
+ Expect(mgr.InstallBackend(ctx, op("vllm-development"), nil)).To(Succeed())
+
+ // The backend node WAS asked, which is the negative control: a
+ // fan-out that skipped everything would satisfy the agent
+ // assertion by doing nothing at all.
+ Expect(mc.callSubjects()).To(Equal([]string{controlKey(backend.ID, workerctl.PathBackendInstall)}))
+ rows, err := registry.ListPendingBackendOps(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ for _, row := range rows {
+ Expect(row.NodeID).ToNot(Equal(agent.ID),
+ "an agent node cannot drain a backend op, so it must never be given one")
+ }
+ })
+
+ // The admin fan-out is the third call site of the rule that a failed
+ // control RPC does not demote a node, and it was the unpinned one. The
+ // rule is stated in three places in this package and, before this spec,
+ // pinned only at ListBackends.
+ //
+ // What the demotion buys in production is the fleet-wide eviction this
+ // phase exists to prevent: MarkUnhealthy removes the node from
+ // ListDuePendingBackendOps AND from scheduling, so a frontend replica
+ // re-homing its tunnels would take out every node it has an op for.
+ Context("when the install RPC could not be routed", func() {
+ It("records the failure without demoting the node", func() {
+ node := registerHealthyBackend("worker-unroutable", "10.0.0.8:50051")
+ mc.scriptUnroutable(node.ID)
+
+ err := mgr.InstallBackend(ctx, op("vllm"), nil)
+ Expect(err).To(HaveOccurred())
+ // Not the still-installing soft path: that branch returns before
+ // the failure handling this spec is about, so without this the
+ // assertions below could pass on the wrong branch.
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeFalse())
+
+ // The recorded failure witnesses that the fan-out ran and
+ // reached the branch that used to demote.
+ rows, listErr := registry.ListPendingBackendOps(ctx)
+ Expect(listErr).ToNot(HaveOccurred())
+ Expect(rows).To(HaveLen(1))
+ Expect(rows[0].Attempts).To(Equal(1))
+
+ after, getErr := registry.Get(ctx, node.ID)
+ Expect(getErr).ToNot(HaveOccurred())
+ Expect(after.Status).To(Equal(StatusHealthy))
+ })
+ })
+
Context("ListBackends clears confirmed install rows", func() {
It("deletes the pending_backend_ops install row when the backend is reported installed on its target node", func() {
node := registerHealthyBackend("worker-a", "10.0.0.5:50051")
- // Pre-stage: simulate an admin install that timed out at the NATS
- // round-trip, leaving an install row in the queue.
- mc.scriptErr(messaging.SubjectNodeBackendInstall(node.ID), nats.ErrTimeout)
+ // Pre-stage: simulate an admin install whose control RPC ran out
+ // of budget, leaving an install row in the queue.
+ mc.scriptTimeout(node.ID)
err := mgr.InstallBackend(ctx, op("vllm"), nil)
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue())
@@ -581,7 +395,8 @@ var _ = Describe("DistributedBackendManager", func() {
// Worker finishes installing in the background. backend.list now
// confirms presence; ListBackends should proactively clear the row.
- mc.scriptReply(messaging.SubjectNodeBackendList(node.ID), messaging.BackendListReply{
+ mc.clearTimeout(node.ID)
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendList), messaging.BackendListReply{
Backends: []messaging.NodeBackendInfo{{Name: "vllm"}},
})
@@ -599,7 +414,7 @@ var _ = Describe("DistributedBackendManager", func() {
Expect(registry.UpsertPendingBackendOp(ctx, node.ID, "vllm", OpBackendUpgrade, []byte("[]"))).To(Succeed())
- mc.scriptReply(messaging.SubjectNodeBackendList(node.ID), messaging.BackendListReply{
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendList), messaging.BackendListReply{
Backends: []messaging.NodeBackendInfo{{Name: "vllm"}},
})
@@ -615,8 +430,8 @@ var _ = Describe("DistributedBackendManager", func() {
It("invokes progressCb once per worker-published progress event", func() {
node := registerHealthyBackend("worker-prog", "10.0.0.7:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, Address: "10.0.0.7:50051"})
- mc.scheduleProgressPublish(node.ID, "op-prog-1", []messaging.BackendInstallProgressEvent{
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendInstall), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.7:50051"})
+ mc.scriptProgress(controlKey(node.ID, workerctl.PathBackendInstall), []messaging.BackendInstallProgressEvent{
{OpID: "op-prog-1", NodeID: node.ID, Backend: "vllm", FileName: "vllm.tar", Current: "100 MB", Total: "1 GB", Percentage: 10},
{OpID: "op-prog-1", NodeID: node.ID, Backend: "vllm", FileName: "vllm.tar", Current: "1 GB", Total: "1 GB", Percentage: 100},
})
@@ -646,21 +461,20 @@ var _ = Describe("DistributedBackendManager", func() {
}, "1s").Should(Equal(2))
mu.Lock()
defer mu.Unlock()
- // The adapter dispatches each progress event to its own goroutine
- // (see unloader.go: `go onProgress(ev)`) so two events emitted back
- // to back can land at the bridge in either order. Assert the set of
- // percentages observed contains both ticks, rather than depending
- // on goroutine scheduling for ordering.
- pcts := []float64{pcCalls[0].Percentage, pcCalls[1].Percentage}
- Expect(pcts).To(ConsistOf(10.0, 100.0))
+ // ORDER, not a set. Progress lines are read off the install
+ // response on the caller's own goroutine, so the order the
+ // worker wrote them in is the order the bridge sees; the
+ // goroutine-per-event dispatch that made this best-effort is
+ // gone with the subscription it existed for.
+ Expect([]float64{pcCalls[0].Percentage, pcCalls[1].Percentage}).To(Equal([]float64{10.0, 100.0}))
})
})
Context("InstallBackend tolerates silent (pre-Phase-2) workers", func() {
It("completes successfully even when no progress events are ever published", func() {
node := registerHealthyBackend("worker-silent", "10.0.0.8:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, Address: "10.0.0.8:50051"})
- // NO scheduleProgressPublish call - silent worker.
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendInstall), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.8:50051"})
+ // NO scriptProgress call - silent worker.
var ticks int
var mu sync.Mutex
@@ -695,14 +509,14 @@ var _ = Describe("DistributedBackendManager", func() {
mgr = NewDistributedBackendManager(appCfg, nil, adapter, registry, sink)
// stubLocalBackendManager mirrors the production behaviour
// where the frontend node rarely has the backend installed
- // locally - the NATS fan-out is what these specs verify.
+ // locally - the control-plane fan-out is what these specs verify.
mgr.local = stubLocalBackendManager{}
})
It("emits a success entry for each healthy node visited", func() {
node := registerHealthyBackend("worker-ok", "10.0.0.9:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID),
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.9:50051"})
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.9:50051"})
opVal := op("vllm")
opVal.ID = "op-node-success"
@@ -714,9 +528,9 @@ var _ = Describe("DistributedBackendManager", func() {
Expect(calls[len(calls)-1].NodeName).To(Equal("worker-ok"))
})
- It("emits a running_on_worker entry when NATS times out", func() {
+ It("emits a running_on_worker entry when the install RPC runs out of budget", func() {
node := registerHealthyBackend("worker-slow", "10.0.0.10:50051")
- mc.scriptErr(messaging.SubjectNodeBackendInstall(node.ID), nats.ErrTimeout)
+ mc.scriptTimeout(node.ID)
opVal := op("vllm")
opVal.ID = "op-node-slow"
@@ -730,9 +544,9 @@ var _ = Describe("DistributedBackendManager", func() {
It("emits downloading entries from progress events", func() {
node := registerHealthyBackend("worker-dl", "10.0.0.11:50051")
- mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID),
+ mc.scriptReply(controlKey(node.ID, workerctl.PathBackendInstall),
messaging.BackendInstallReply{Success: true})
- mc.scheduleProgressPublish(node.ID, "op-node-dl", []messaging.BackendInstallProgressEvent{
+ mc.scriptProgress(controlKey(node.ID, workerctl.PathBackendInstall), []messaging.BackendInstallProgressEvent{
{OpID: "op-node-dl", NodeID: node.ID, Backend: "vllm", FileName: "vllm.tar", Current: "1 GB", Total: "1 GB", Percentage: 100, Phase: messaging.PhaseDownloading},
})
@@ -765,13 +579,13 @@ var _ = Describe("DistributedBackendManager", func() {
// upgrade should skip it.
scriptInstalled := func(backend string, nodeIDs ...string) {
for _, id := range nodeIDs {
- mc.scriptReply(messaging.SubjectNodeBackendList(id),
+ mc.scriptReply(controlKey(id, workerctl.PathBackendList),
messaging.BackendListReply{Backends: []messaging.NodeBackendInfo{{Name: backend}}})
}
}
scriptNoBackends := func(nodeIDs ...string) {
for _, id := range nodeIDs {
- mc.scriptReply(messaging.SubjectNodeBackendList(id),
+ mc.scriptReply(controlKey(id, workerctl.PathBackendList),
messaging.BackendListReply{Backends: nil})
}
}
@@ -782,9 +596,9 @@ var _ = Describe("DistributedBackendManager", func() {
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
scriptInstalled("vllm-development", n1.ID, n2.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: false, Error: "image manifest not found"})
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n2.ID),
+ mc.scriptReply(controlKey(n2.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: false, Error: "registry unauthorized"})
err := mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)
@@ -800,7 +614,7 @@ var _ = Describe("DistributedBackendManager", func() {
It("returns nil", func() {
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
scriptInstalled("vllm-development", n1.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
Expect(mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)).To(Succeed())
})
@@ -818,9 +632,9 @@ var _ = Describe("DistributedBackendManager", func() {
scriptInstalled("cpu-insightface-development", has.ID)
scriptNoBackends(lacks.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(has.ID),
+ mc.scriptReply(controlKey(has.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
- // Deliberately don't script SubjectNodeBackendUpgrade for `lacks`:
+ // Deliberately don't script backend.upgrade for `lacks`:
// if the manager attempts it, the scripted-client default returns
// fakeNoRespondersErr and the assertion below fails loudly.
@@ -829,7 +643,7 @@ var _ = Describe("DistributedBackendManager", func() {
mc.mu.Lock()
defer mc.mu.Unlock()
for _, call := range mc.calls {
- Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendUpgrade(lacks.ID)),
+ Expect(call.Subject).ToNot(Equal(controlKey(lacks.ID, workerctl.PathBackendUpgrade)),
"upgrade leaked to %s which does not have the backend installed", lacks.Name)
}
})
@@ -846,9 +660,9 @@ var _ = Describe("DistributedBackendManager", func() {
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
scriptInstalled("vllm-development", n1.ID, n2.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n2.ID),
+ mc.scriptReply(controlKey(n2.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
op := upgradeOp("vllm-development")
@@ -859,10 +673,10 @@ var _ = Describe("DistributedBackendManager", func() {
defer mc.mu.Unlock()
upgraded := map[string]bool{}
for _, call := range mc.calls {
- if call.Subject == messaging.SubjectNodeBackendUpgrade(n1.ID) {
+ if call.Subject == controlKey(n1.ID, workerctl.PathBackendUpgrade) {
upgraded[n1.ID] = true
}
- if call.Subject == messaging.SubjectNodeBackendUpgrade(n2.ID) {
+ if call.Subject == controlKey(n2.ID, workerctl.PathBackendUpgrade) {
upgraded[n2.ID] = true
}
}
@@ -876,7 +690,7 @@ var _ = Describe("DistributedBackendManager", func() {
scriptInstalled("vllm-development", has.ID)
scriptNoBackends(lacks.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(has.ID),
+ mc.scriptReply(controlKey(has.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
op := upgradeOp("vllm-development")
@@ -888,9 +702,9 @@ var _ = Describe("DistributedBackendManager", func() {
mc.mu.Lock()
defer mc.mu.Unlock()
for _, call := range mc.calls {
- Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendUpgrade(has.ID)),
+ Expect(call.Subject).ToNot(Equal(controlKey(has.ID, workerctl.PathBackendUpgrade)),
"a node-scoped upgrade for %s must not touch other nodes", lacks.Name)
- Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendUpgrade(lacks.ID)),
+ Expect(call.Subject).ToNot(Equal(controlKey(lacks.ID, workerctl.PathBackendUpgrade)),
"the target node lacks the backend; nothing should be sent")
}
})
@@ -908,37 +722,84 @@ var _ = Describe("DistributedBackendManager", func() {
mc.mu.Lock()
defer mc.mu.Unlock()
for _, call := range mc.calls {
- Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendUpgrade(n1.ID)))
- Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendInstall(n1.ID)))
+ Expect(call.Subject).ToNot(Equal(controlKey(n1.ID, workerctl.PathBackendUpgrade)))
+ Expect(call.Subject).ToNot(Equal(controlKey(n1.ID, workerctl.PathBackendInstall)))
}
})
})
- // Rolling-update fallback: pre-2026-05-08 workers don't subscribe to
- // backend.upgrade, so the manager catches nats.ErrNoResponders and
+ // The still-installing surfacing has TWO call sites, InstallBackend and
+ // this one, and was pinned only at InstallBackend. Dropping it here
+ // reports a spent budget as GREEN SUCCESS: the admin sees the upgrade
+ // finished while the worker is still re-pulling gigabytes, and the row
+ // the reconciler needs to confirm it is invisible in the UI.
+ It("reports an upgrade that ran out of budget as still installing, not as success", func() {
+ n := registerHealthyBackend("worker-slow", "10.0.0.1:50051")
+ scriptInstalled("vllm-development", n.ID)
+ // Only the upgrade verb hangs: backend.list must still answer, or
+ // the manager never gets as far as the node it would upgrade.
+ mc.scriptHang(controlKey(n.ID, workerctl.PathBackendUpgrade))
+ slow := &DistributedBackendManager{
+ local: stubLocalBackendManager{},
+ adapter: NewRemoteUnloaderAdapter(nil, nil, mc.controlClient(), time.Minute, 200*time.Millisecond),
+ registry: registry,
+ }
+
+ err := slow.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue(),
+ "a spent budget must not read as a finished upgrade, got %v", err)
+
+ rows, listErr := registry.ListPendingBackendOps(ctx)
+ Expect(listErr).ToNot(HaveOccurred())
+ Expect(rows).To(HaveLen(1), "the row the reconciler confirms the outcome from must survive")
+ })
+
+ // Rolling-update fallback: pre-2026-05-08 workers do not serve
+ // backend.upgrade, so the manager catches the worker's own 404 and
// re-fires the legacy backend.install Force=true on the same node.
// Drop these specs once the fallback path itself is removed (see
// managers_distributed.go UpgradeBackend godoc for the deprecation).
Context("rolling-update fallback", func() {
- It("falls back to backend.install Force=true when upgrade returns ErrNoResponders", func() {
+ It("falls back to backend.install Force=true when the worker does not serve the upgrade verb", func() {
n := registerHealthyBackend("worker-old", "10.0.0.1:50051")
scriptInstalled("vllm-development", n.ID)
- // Old worker: no subscriber on backend.upgrade.
- mc.scriptNoResponders(messaging.SubjectNodeBackendUpgrade(n.ID))
+ // Old worker: it answers 404 for a verb it does not serve.
+ mc.scriptUnsupported(controlKey(n.ID, workerctl.PathBackendUpgrade))
// Fallback re-fires legacy backend.install with Force=true.
- mc.scriptReplyMatching(messaging.SubjectNodeBackendInstall(n.ID),
+ mc.scriptReplyMatching(controlKey(n.ID, workerctl.PathBackendInstall),
func(req messaging.BackendInstallRequest) bool { return req.Force },
- messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
Expect(mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)).To(Succeed())
})
- It("returns the upgrade error when it is not ErrNoResponders", func() {
+ // The negative direction, and it is the one that matters: the
+ // fallback re-fires a DESTRUCTIVE force-reinstall, so it may run
+ // only on the worker's own "I do not serve that verb". A worker
+ // this frontend merely failed to reach has said nothing, and
+ // retrying a force-reinstall on silence is how a lost route becomes
+ // a reinstall of every backend on the fleet.
+ It("does NOT fall back to the legacy force install when the upgrade could not be routed", func() {
+ n := registerHealthyBackend("worker-unroutable", "10.0.0.1:50051")
+ scriptInstalled("vllm-development", n.ID)
+ // backend.upgrade is deliberately not scripted, so the worker
+ // fails to SERVE it rather than answering that it lacks it.
+ // backend.install is not scripted either, so a fallback would
+ // be visible in the calls below.
+
+ err := mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)
+ Expect(err).To(HaveOccurred())
+ Expect(mc.callSubjects()).ToNot(ContainElement(controlKey(n.ID, workerctl.PathBackendInstall)),
+ "an unroutable upgrade must not re-fire a force-reinstall")
+ })
+
+ It("returns the upgrade error when the worker served the verb and refused", func() {
n := registerHealthyBackend("worker-bad", "10.0.0.1:50051")
scriptInstalled("vllm-development", n.ID)
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(n.ID),
+ mc.scriptReply(controlKey(n.ID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: false, Error: "disk full"})
err := mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)
@@ -954,9 +815,9 @@ var _ = Describe("DistributedBackendManager", func() {
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
- mc.scriptReply(messaging.SubjectNodeBackendDelete(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendDelete),
messaging.BackendDeleteReply{Success: false, Error: "backend not installed"})
- mc.scriptReply(messaging.SubjectNodeBackendDelete(n2.ID),
+ mc.scriptReply(controlKey(n2.ID, workerctl.PathBackendDelete),
messaging.BackendDeleteReply{Success: false, Error: "permission denied"})
err := mgr.DeleteBackend("vllm-development")
@@ -971,7 +832,7 @@ var _ = Describe("DistributedBackendManager", func() {
Context("when every node succeeds", func() {
It("returns nil", func() {
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
- mc.scriptReply(messaging.SubjectNodeBackendDelete(n1.ID),
+ mc.scriptReply(controlKey(n1.ID, workerctl.PathBackendDelete),
messaging.BackendDeleteReply{Success: true})
Expect(mgr.DeleteBackend("vllm-development")).To(Succeed())
})
diff --git a/core/services/nodes/model_router.go b/core/services/nodes/model_router.go
index 2d29fe528a92..661c6526c2c0 100644
--- a/core/services/nodes/model_router.go
+++ b/core/services/nodes/model_router.go
@@ -68,7 +68,7 @@ func (a *ModelRouterAdapter) Route(ctx context.Context, backend, modelID, modelN
// If file staging is configured, it's already wrapped with FileStagingClient
// by SmartRouter. Use NewModelWithClient so the wrapper is preserved when
// the ModelLoader returns this model on subsequent requests.
- m := model.NewModelWithClient(modelID, result.Node.Address, result.Client)
+ m := model.NewModelWithClient(modelID, result.WorkerLocalAddress, result.Client)
// Publish the picked node ID into the per-request holder attached to
// ctx (by middleware.ExposeNodeHeader). No-op when the holder is
@@ -80,7 +80,7 @@ func (a *ModelRouterAdapter) Route(ctx context.Context, backend, modelID, modelN
// concurrently to different replicas.
distributedhdr.Stamp(ctx, result.Node.ID)
- xlog.Info("Model routed to remote node", "model", modelName, "node", result.Node.Name, "address", result.Node.Address)
+ xlog.Info("Model routed to remote node", "model", modelName, "node", result.Node.Name, "address", result.WorkerLocalAddress)
return m, nil
}
diff --git a/core/services/nodes/model_router_test.go b/core/services/nodes/model_router_test.go
index 9a77d96ae2bb..193c9d54557d 100644
--- a/core/services/nodes/model_router_test.go
+++ b/core/services/nodes/model_router_test.go
@@ -22,6 +22,7 @@ type fakeModelRouterForSmartRouter struct {
nodeModel *NodeModel
findErr error
decrementCalled map[string]int // "nodeID:model" -> count
+ removed []string // "nodeID:model:replica" per RemoveNodeModel
}
func newFakeModelRouterForSmartRouter() *fakeModelRouterForSmartRouter {
@@ -46,9 +47,20 @@ func (f *fakeModelRouterForSmartRouter) DecrementInFlight(_ context.Context, nod
func (f *fakeModelRouterForSmartRouter) IncrementInFlight(_ context.Context, _, _ string, _ int) error {
return nil
}
-func (f *fakeModelRouterForSmartRouter) RemoveNodeModel(_ context.Context, _, _ string, _ int) error {
+func (f *fakeModelRouterForSmartRouter) RemoveNodeModel(_ context.Context, nodeID, modelName string, replicaIndex int) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.removed = append(f.removed, fmt.Sprintf("%s:%s:%d", nodeID, modelName, replicaIndex))
return nil
}
+
+// removedModels lists the replica rows the code under test deleted, so a spec
+// can assert a branch left a row alone rather than only that it returned nil.
+func (f *fakeModelRouterForSmartRouter) removedModels() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.removed...)
+}
func (f *fakeModelRouterForSmartRouter) RemoveAllNodeModelReplicas(_ context.Context, _, _ string) error {
return nil
}
@@ -199,13 +211,15 @@ var _ = Describe("ModelRouterAdapter", func() {
Describe("Route", func() {
It("delegates to SmartRouter and stores release func", func() {
fakeNode := &BackendNode{
- ID: "node-1",
- Name: "test-node",
- Address: "10.0.0.1:50051",
+ ID: "node-1",
+ Name: "test-node",
}
+ // The replica row carries the address now; the node has none. A row
+ // without one is not routable and the warm path declines it.
fakeNM := &NodeModel{
- NodeID: "node-1",
- ModelName: "test-model",
+ NodeID: "node-1",
+ ModelName: "test-model",
+ WorkerLocalAddress: "127.0.0.1:50052",
}
fakeReg := newFakeModelRouterForSmartRouter()
@@ -214,7 +228,7 @@ var _ = Describe("ModelRouterAdapter", func() {
// The fake gRPC client that SmartRouter will use for health check
factory := newFakeBackendClientFactory()
- factory.setClient("10.0.0.1:50051", &fakeBackendClient{healthy: true})
+ factory.setClient("127.0.0.1:50052", &fakeBackendClient{healthy: true})
sr := NewSmartRouter(fakeReg, SmartRouterOptions{
ClientFactory: factory,
diff --git a/core/services/nodes/pending_op_cleanup_test.go b/core/services/nodes/pending_op_cleanup_test.go
index ad8610cc460c..af73ec0667b6 100644
--- a/core/services/nodes/pending_op_cleanup_test.go
+++ b/core/services/nodes/pending_op_cleanup_test.go
@@ -86,8 +86,9 @@ var _ = Describe("DeleteStalePendingBackendOps", func() {
})
It("clears ops behind an unhealthy node with a stale heartbeat (never ages to offline)", func() {
- // A node marked unhealthy on a NATS ErrNoResponders never transitions to
- // offline, so its ops must be reaped via the same stale-heartbeat path.
+ // A node the scheduler demoted for a departed tunnel never transitions
+ // to offline, so its ops must be reaped via the same stale-heartbeat
+ // path.
sick := registerBackend("agx-orin-sick", "10.0.0.7:50051")
Expect(registry.UpsertPendingBackendOp(ctx, sick, "llama-cpp-development", OpBackendUpgrade, nil)).To(Succeed())
Expect(registry.MarkUnhealthy(ctx, sick)).To(Succeed())
diff --git a/core/services/nodes/probe_cache.go b/core/services/nodes/probe_cache.go
index 422e36ede4e2..a5b3e2cb0573 100644
--- a/core/services/nodes/probe_cache.go
+++ b/core/services/nodes/probe_cache.go
@@ -33,7 +33,7 @@ type probeCache struct {
}
// newProbeCache returns a probeCache with the given TTL. Zero TTL disables
-// caching: every call to DoOrCached invokes the probe.
+// caching: every call to DoOrCachedResult invokes the probe.
func newProbeCache(ttl time.Duration) *probeCache {
return &probeCache{
ttl: ttl,
@@ -68,27 +68,47 @@ func (c *probeCache) Invalidate(key string) {
delete(c.seen, key)
}
-// DoOrCached returns true if key is fresh; otherwise it runs probe (coalescing
-// concurrent callers via singleflight) and caches a successful result. Failed
-// probes invalidate the cache, so a transient miss doesn't pin every
-// subsequent request to a re-probe.
-func (c *probeCache) DoOrCached(key string, probe func() bool) bool {
+// DoOrCachedResult returns true if key is fresh; otherwise it runs probe
+// (coalescing concurrent callers via singleflight) and caches a successful
+// result. Failed probes invalidate the cache, so a transient miss does not pin
+// every subsequent request to a re-probe.
+//
+// It is the ONLY entry point. A boolean-only sibling, DoOrCached, stood beside
+// it until probeHealth stopped using it, after which it was production code
+// held green by nothing but its own specs; the shim that reads it as a boolean
+// now lives in probe_cache_test.go, where its one caller is.
+//
+// The second result is the reason the probe never reached the backend, or nil
+// when it did.
+//
+// The second result travels through the SINGLEFLIGHT, which is the whole reason
+// it is not simply a variable the caller closes over. A closed-over variable is
+// only written by the goroutine that actually runs the probe; every other
+// caller coalesced into that flight adopts the leader's boolean and sees its own
+// unset variable, so the leader would correctly decline to reap while its
+// joiners reaped on the very same observation. Carrying it in singleflight's
+// error slot hands every joiner the leader's reason as well as its answer.
+//
+// A probe that could not reach the backend is NOT cached either way. Caching it
+// as fresh would hide a genuinely dead backend behind a network blip, and
+// caching it as a failure is what Invalidate already does.
+func (c *probeCache) DoOrCachedResult(key string, probe func() (bool, error)) (bool, error) {
if c.IsFresh(key) {
- return true
+ return true, nil
}
- v, _, _ := c.flight.Do(key, func() (any, error) {
+ v, unreached, _ := c.flight.Do(key, func() (any, error) {
// Double-check after potentially waiting: another caller in this
// flight may have just populated the cache.
if c.IsFresh(key) {
return true, nil
}
- ok := probe()
+ ok, unreached := probe()
if ok {
c.markFresh(key)
} else {
c.Invalidate(key)
}
- return ok, nil
+ return ok, unreached
})
- return v.(bool)
+ return v.(bool), unreached
}
diff --git a/core/services/nodes/probe_cache_test.go b/core/services/nodes/probe_cache_test.go
index 58e6fa111cb9..42eaf35c69fc 100644
--- a/core/services/nodes/probe_cache_test.go
+++ b/core/services/nodes/probe_cache_test.go
@@ -1,14 +1,31 @@
package nodes
import (
+ "errors"
"sync"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "golang.org/x/sync/singleflight"
)
+// doOrCached drives the production entry point with a boolean-only probe,
+// which is what most of these specs are about.
+//
+// It is a spec helper and not a method, deliberately. It WAS a method, and once
+// probeHealth moved to DoOrCachedResult it became production code with no
+// production caller, kept green by these specs alone. Moving it here keeps the
+// convenience where its only user is and stops the shim being mistaken for a
+// supported way to probe.
+func doOrCached(c *probeCache, key string, probe func() bool) bool {
+ GinkgoHelper()
+ alive, unreached := c.DoOrCachedResult(key, func() (bool, error) { return probe(), nil })
+ Expect(unreached).To(BeNil())
+ return alive
+}
+
var _ = Describe("probeCache", func() {
It("invokes the probe on a cold cache and caches success", func() {
c := newProbeCache(time.Minute)
@@ -18,9 +35,9 @@ var _ = Describe("probeCache", func() {
return true
}
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
// Cached: probe ran once.
Expect(atomic.LoadInt32(&calls)).To(Equal(int32(1)))
@@ -36,9 +53,9 @@ var _ = Describe("probeCache", func() {
return true
}
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
time.Sleep(5 * time.Millisecond)
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
Expect(atomic.LoadInt32(&calls)).To(Equal(int32(2)))
})
@@ -54,16 +71,16 @@ var _ = Describe("probeCache", func() {
// First probe fails — must NOT be cached.
result.Store(false)
- Expect(c.DoOrCached("k", probe)).To(BeFalse())
+ Expect(doOrCached(c, "k", probe)).To(BeFalse())
Expect(c.IsFresh("k")).To(BeFalse())
// Recover: second probe succeeds and is cached.
result.Store(true)
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
Expect(c.IsFresh("k")).To(BeTrue())
// Third call short-circuits on the fresh entry.
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
Expect(atomic.LoadInt32(&calls)).To(Equal(int32(2)))
})
@@ -90,7 +107,7 @@ var _ = Describe("probeCache", func() {
go func(i int) {
defer wg.Done()
<-start
- results[i] = c.DoOrCached("k", probe)
+ results[i] = doOrCached(c, "k", probe)
}(i)
}
@@ -104,12 +121,90 @@ var _ = Describe("probeCache", func() {
}
})
+ It("hands every coalesced joiner the leader's REASON, not just its answer", func() {
+ // The hole this shape exists to close, and the one a closed-over
+ // variable reintroduces. The reason is written only in the goroutine
+ // that runs the probe; every caller coalesced into that flight would
+ // read its own unset variable and see nil. In production that means the
+ // leader correctly declines to reap a replica on an unreachable worker
+ // while all seven joiners reap it, on the leader's own observation.
+ //
+ // The FIRST version of this spec raced eight goroutines at the cache
+ // and hoped they coalesced. Nothing made them: a goroutine that arrived
+ // after the leader's flight finished started its own, re-entered the
+ // probe and double-closed a channel, so the spec panicked about one run
+ // in three. Its comment claimed the probe blocked until every goroutine
+ // was inside flight.Do, which was the design intended rather than the
+ // one written, and that gap was exactly the panic.
+ //
+ // This version does not hope. singleflight.DoChan registers its channel
+ // on the in-flight call under the group's own mutex and returns WITHOUT
+ // running its function (x/sync@v0.22.0 singleflight.go:127-132), so
+ // calling it while the leader is provably parked inside the probe joins
+ // that exact flight, with no window and no scheduler dependency. The
+ // group is reachable because this spec lives in the package.
+ c := newProbeCache(time.Minute)
+ unreached := errors.New("no route to the worker")
+
+ // Buffered, and sent on rather than closed: a probe that somehow ran
+ // twice must fail an assertion, not panic and take the suite with it.
+ entered := make(chan struct{}, 4)
+ release := make(chan struct{})
+ var calls int32
+ probe := func() (bool, error) {
+ atomic.AddInt32(&calls, 1)
+ entered <- struct{}{}
+ <-release
+ return false, unreached
+ }
+
+ type leaderResult struct {
+ alive bool
+ unreached error
+ }
+ leader := make(chan leaderResult, 1)
+ go func() {
+ defer GinkgoRecover()
+ alive, reason := c.DoOrCachedResult("k", probe)
+ leader <- leaderResult{alive: alive, unreached: reason}
+ }()
+
+ // The leader is now inside the probe, so the group holds an entry for
+ // "k" and will hold it until the probe returns.
+ Eventually(entered, "10s").Should(Receive())
+
+ // Deterministically coalesced. This function must never run; if the
+ // join failed it would, and the assertion below on the probe count
+ // would catch it too.
+ joined := c.flight.DoChan("k", func() (any, error) {
+ Fail("DoChan started its own flight, so nothing was coalesced")
+ return false, nil
+ })
+
+ close(release)
+
+ var got leaderResult
+ Eventually(leader, "10s").Should(Receive(&got))
+ Expect(got.alive).To(BeFalse())
+ Expect(got.unreached).To(MatchError(unreached), "the caller that RAN the probe must get the reason")
+
+ var shared singleflight.Result
+ Eventually(joined, "10s").Should(Receive(&shared))
+ Expect(shared.Shared).To(BeTrue(), "this caller did not actually join the leader's flight")
+ Expect(shared.Val).To(Equal(false))
+ Expect(shared.Err).To(MatchError(unreached),
+ "a joiner got the answer without the reason, which is how a joiner reaps what the leader would not")
+
+ Expect(atomic.LoadInt32(&calls)).To(Equal(int32(1)),
+ "the probe must have run exactly once")
+ })
+
It("treats different keys independently", func() {
c := newProbeCache(time.Minute)
var aCalls, bCalls int32
- Expect(c.DoOrCached("a", func() bool { atomic.AddInt32(&aCalls, 1); return true })).To(BeTrue())
- Expect(c.DoOrCached("b", func() bool { atomic.AddInt32(&bCalls, 1); return true })).To(BeTrue())
- Expect(c.DoOrCached("a", func() bool { atomic.AddInt32(&aCalls, 1); return true })).To(BeTrue())
+ Expect(doOrCached(c, "a", func() bool { atomic.AddInt32(&aCalls, 1); return true })).To(BeTrue())
+ Expect(doOrCached(c, "b", func() bool { atomic.AddInt32(&bCalls, 1); return true })).To(BeTrue())
+ Expect(doOrCached(c, "a", func() bool { atomic.AddInt32(&aCalls, 1); return true })).To(BeTrue())
Expect(atomic.LoadInt32(&aCalls)).To(Equal(int32(1)))
Expect(atomic.LoadInt32(&bCalls)).To(Equal(int32(1)))
@@ -123,9 +218,9 @@ var _ = Describe("probeCache", func() {
return true
}
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
Expect(atomic.LoadInt32(&calls)).To(Equal(int32(3)))
})
@@ -137,9 +232,9 @@ var _ = Describe("probeCache", func() {
atomic.AddInt32(&calls, 1)
return true
}
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
c.Invalidate("k")
- Expect(c.DoOrCached("k", probe)).To(BeTrue())
+ Expect(doOrCached(c, "k", probe)).To(BeTrue())
Expect(atomic.LoadInt32(&calls)).To(Equal(int32(2)))
})
})
diff --git a/core/services/nodes/reconciler.go b/core/services/nodes/reconciler.go
index 62cc73e1545e..b41c62f040c2 100644
--- a/core/services/nodes/reconciler.go
+++ b/core/services/nodes/reconciler.go
@@ -11,9 +11,7 @@ import (
"github.com/mudler/LocalAI/core/services/advisorylock"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
- grpcclient "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/xlog"
- "github.com/nats-io/nats.go"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
@@ -34,17 +32,32 @@ const (
// ProbeUnreachable: nothing is listening (connection refused), or the
// backend answered and affirmatively reported itself unhealthy.
ProbeUnreachable
+ // ProbeUnknown: the probe was never made, because this frontend has no way
+ // to reach the worker at all (no tunnel dialer wired, or none for this
+ // node). It is NOT ProbeUnreachable and must never be folded into it:
+ // unreachable is an observation about a backend and the reaper deletes rows
+ // on it, while this is a statement about THIS process and says nothing
+ // about the worker, which may be running the model perfectly well.
+ //
+ // It is appended rather than made the zero value on purpose. ProbeAlive is
+ // the zero value already, and renumbering the set would silently change the
+ // meaning of every stored or hard-coded outcome.
+ ProbeUnknown
)
// ModelProber checks the state of a model's backend process.
// Defaulted to a gRPC health probe but overridable for tests so we don't
// need to stand up a real server.
type ModelProber interface {
- Probe(ctx context.Context, address string) ProbeOutcome
+ // Probe checks the backend at address on node nodeID. The node is needed
+ // as well as the address because address is a port INSIDE the worker,
+ // reached over the tunnel that worker holds, and there is no route to it
+ // that does not name the node.
+ Probe(ctx context.Context, nodeID, address string) ProbeOutcome
}
// NodeProcessLister asks a worker which model backend processes it currently
-// has running. Implemented by RemoteUnloaderAdapter over NATS.
+// has running. Implemented by RemoteUnloaderAdapter over the worker's tunnel.
//
// This is the sounder liveness signal: the worker owns the process table, so
// its answer does not depend on whether a backend is busy. A health probe
@@ -60,25 +73,51 @@ type NodeProcessLister interface {
// as death.
const probeTimeout = 1 * time.Second
-// grpcModelProber does a short HealthCheck on the model's stored gRPC address.
-type grpcModelProber struct{ token string }
+// grpcModelProber does a short HealthCheck on the model's stored gRPC address,
+// through the tunnel of the node that address belongs to.
+type grpcModelProber struct{ clients BackendClientFactory }
-func (g grpcModelProber) Probe(ctx context.Context, address string) ProbeOutcome {
- client := grpcclient.NewClientWithToken(address, false, nil, false, g.token)
+func (g grpcModelProber) Probe(ctx context.Context, nodeID, address string) ProbeOutcome {
+ client, err := g.clients.NewClientForNode(nodeID, address, false)
+ if err != nil {
+ // Never ProbeUnreachable: the reaper deletes a row on that answer, and
+ // this frontend not being able to reach a worker is no evidence that
+ // the worker stopped running the model.
+ xlog.Error("Cannot probe a model: no way to reach the worker",
+ "node", nodeID, "address", address, "error", err)
+ return ProbeUnknown
+ }
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
ok, err := client.HealthCheck(probeCtx)
+ if unreached := unroutable(client); unreached != nil {
+ // The RPC never reached a backend. classifyProbeOutcome cannot tell:
+ // gRPC hands it codes.Unavailable for a worker this frontend has no
+ // route to and for a backend process that has died, and the reaper
+ // deletes rows on the second.
+ xlog.Warn("Could not probe a model: no route to the worker",
+ "node", nodeID, "address", address, "error", unreached)
+ return ProbeUnknown
+ }
return classifyProbeOutcome(ok, err)
}
// classifyProbeOutcome maps a HealthCheck result onto a ProbeOutcome.
//
+// It is only ever reached for a probe that DID reach the worker. That is a
+// precondition and not an observation it can make for itself: its caller asks
+// the transport first and answers ProbeUnknown when the dial failed. Without
+// that step the Unavailable case below is wrong, because a worker this frontend
+// cannot route to produces exactly the same code as a backend that has died,
+// and only one of the two should cost a row.
+//
// The gRPC client is lazy, so connection failures surface on the RPC rather
// than at dial time, and the status code tells the two cases apart:
//
// - DeadlineExceeded: the transport was fine but nothing serviced the RPC in
// time. That is a backend stuck inside a long synchronous request.
-// - Unavailable: nothing is listening. The process is gone.
+// - Unavailable: the worker was reached and nothing is listening on that
+// port. The process is gone.
//
// A blackholed network also yields DeadlineExceeded and is therefore treated as
// busy. That is deliberate: whole-node failures are the health monitor's job
@@ -122,7 +161,7 @@ type ReplicaReconciler struct {
registry *NodeRegistry
scheduler ModelScheduler // interface for scheduling new models
unloader NodeCommandSender
- adapter *RemoteUnloaderAdapter // NATS sender for pending-op drain
+ adapter *RemoteUnloaderAdapter // control-RPC sender for the pending-op drain
prober ModelProber // health probe for model gRPC addrs
db *gorm.DB
interval time.Duration
@@ -170,14 +209,20 @@ type ReplicaReconcilerOptions struct {
Registry *NodeRegistry
Scheduler ModelScheduler
Unloader NodeCommandSender
- // Adapter is the NATS sender used to retry pending backend ops. When nil,
+ // Adapter is the control-RPC sender used to retry pending backend ops. When nil,
// the state-reconciler pending-drain pass is a no-op (single-node mode).
Adapter *RemoteUnloaderAdapter
- // RegistrationToken is used by the default gRPC prober when probing model
- // addresses. Matches the worker's token so HealthCheck auth succeeds.
+ // RegistrationToken is the bearer token the default gRPC prober presents to
+ // a worker's backends. It matters only when ClientFactory is unset, since
+ // the factory carries its own; a prober built from the token alone can
+ // reach no worker at all and reports ProbeUnknown for every model.
RegistrationToken string
// Prober overrides the default gRPC health probe (used by tests).
Prober ModelProber
+ // ClientFactory builds the gRPC clients the default prober uses. It is what
+ // carries the worker tunnel dialer; without it the default prober can reach
+ // no worker and says so on every probe.
+ ClientFactory BackendClientFactory
// ProcessLister overrides the default worker process query. When nil and
// no Adapter is set, the worker-authoritative pass is skipped entirely and
// only the port probe runs.
@@ -210,7 +255,14 @@ func NewReplicaReconciler(opts ReplicaReconcilerOptions) *ReplicaReconciler {
}
prober := opts.Prober
if prober == nil {
- prober = grpcModelProber{token: opts.RegistrationToken}
+ clients := opts.ClientFactory
+ if clients == nil {
+ // No tunnel dialer was wired. The prober then refuses every probe
+ // with ProbeUnknown rather than dialling addresses directly, which
+ // is loud in the log and leaves every row alone.
+ clients = &tokenClientFactory{token: opts.RegistrationToken}
+ }
+ prober = grpcModelProber{clients: clients}
}
pressureThreshold := opts.PressureThreshold
if pressureThreshold == 0 {
@@ -341,14 +393,17 @@ func (rc *ReplicaReconciler) drainPendingBackendOps(ctx context.Context) {
// Pending-op drain for admin upgrade — fires backend.upgrade so
// the slow re-pull doesn't head-of-line-block install traffic on
// the same worker. Falls back to the legacy backend.install
- // Force=true path on nats.ErrNoResponders for old workers that
- // don't subscribe to backend.upgrade yet (rolling-update window).
- // Reconciler retries are background reconciliation with no live
- // admin watching a progress bar, so opID/onProgress are empty —
- // the adapter skips the progress subscription entirely.
+ // Force=true path when the worker answers that it does not serve
+ // backend.upgrade (rolling-update window). Reconciler retries are
+ // background reconciliation with no live admin watching a progress
+ // bar, so opID/onProgress are empty and no progress is streamed.
reply, err := rc.adapter.UpgradeBackend(op.NodeID, op.Backend, string(op.Galleries), "", "", "", 0, "", nil)
if err != nil {
- if errors.Is(err, nats.ErrNoResponders) {
+ // Only the worker's own "I do not serve that verb" may
+ // re-fire a force-reinstall. An unroutable worker has said
+ // nothing, and retrying a destructive verb on silence is how a
+ // lost route becomes a reinstall.
+ if errors.Is(err, ErrWorkerControlUnsupported) {
instReply, instErr := rc.adapter.installWithForceFallback(op.NodeID, op.Backend, string(op.Galleries), "", "", "", 0, "", nil)
if instErr != nil {
applyErr = instErr
@@ -376,22 +431,19 @@ func (rc *ReplicaReconciler) drainPendingBackendOps(ctx context.Context) {
continue
}
- // ErrNoResponders means the node has no active NATS subscription for
- // this subject. Either its connection dropped, or it's the wrong
- // node type entirely. Mark unhealthy so the health monitor's
- // heartbeat-only pass doesn't immediately flip it back — and so
- // ListDuePendingBackendOps (which filters by status=healthy) stops
- // picking the row until the node genuinely recovers.
- if errors.Is(applyErr, nats.ErrNoResponders) {
- xlog.Warn("Reconciler: no NATS responders — marking node unhealthy",
- "op", op.Op, "backend", op.Backend, "node", op.NodeID)
- _ = rc.registry.MarkUnhealthy(ctx, op.NodeID)
- }
+ // A failed op does not demote the node. A control RPC fails when THIS
+ // frontend cannot route to the worker, which a worker re-homing its
+ // tunnel does while it is heartbeating and serving. Demoting on it
+ // would take the node out of ListDuePendingBackendOps and out of
+ // scheduling for a reason that has nothing to do with the node. The row
+ // keeps its backoff and its dead-letter cap; absence is a separate fact
+ // read from the database by the scheduler (cluster.Presence).
// Dead-letter cap: after maxAttempts the row is the reconciler
// equivalent of a poison message. Delete it loudly so the queue
- // doesn't churn NATS every tick forever — operators can re-issue
- // the op from the UI if they still want it applied.
+ // doesn't churn a control RPC at the worker every tick forever —
+ // operators can re-issue the op from the UI if they still want it
+ // applied.
if op.Attempts+1 >= maxPendingBackendOpAttempts {
xlog.Error("Reconciler: abandoning pending backend op after max attempts",
"op", op.Op, "backend", op.Backend, "node", op.NodeID,
@@ -469,7 +521,15 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
return
}
seen[m.ID] = struct{}{}
- switch rc.prober.Probe(ctx, m.Address) {
+ switch rc.prober.Probe(ctx, m.NodeID, m.WorkerLocalAddress) {
+ case ProbeUnknown:
+ // This frontend could not reach the worker to ask. The streak is
+ // left exactly as it was: neither cleared, which would forgive a
+ // backend that really is dead, nor advanced, which would reap every
+ // model in the fleet the moment the tunnel wiring broke.
+ xlog.Warn("Reconciler: could not probe a model, leaving its row alone",
+ "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress)
+ continue
case ProbeAlive:
rc.clearProbeFailures(m.ID)
// Bump updated_at so we don't probe this row again immediately.
@@ -480,14 +540,14 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
// Reachable but mid-request. Proof of life, so clear the streak.
rc.clearProbeFailures(m.ID)
xlog.Debug("Reconciler: model busy, skipping liveness reap",
- "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address)
+ "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress)
continue
}
failures := rc.recordProbeFailure(m.ID)
if failures < probeFailuresBeforeReap {
xlog.Debug("Reconciler: model unreachable, waiting for more misses before reaping",
- "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address,
+ "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress,
"failures", failures, "threshold", probeFailuresBeforeReap)
continue
}
@@ -497,7 +557,7 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
}
rc.clearProbeFailures(m.ID)
xlog.Warn("Reconciler: model unreachable, removed from registry",
- "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address,
+ "node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress,
"failures", failures)
}
rc.pruneProbeFailures(seen)
@@ -552,9 +612,15 @@ func (rc *ReplicaReconciler) sweepLeakedInFlight(ctx context.Context) {
return
}
seen[m.ID] = struct{}{}
- if rc.prober.Probe(ctx, m.Address) != ProbeAlive {
- // Busy or unreachable. Busy means the counter may well be real;
- // unreachable is the reaper's business, not the sweeper's.
+ if rc.prober.Probe(ctx, m.NodeID, m.WorkerLocalAddress) != ProbeAlive {
+ // Anything but alive, and the three of them agree on what this
+ // sweeper should do even though they disagree about everything
+ // else. Busy: the counter may well be real, so leave it.
+ // Unreachable: the row is the reaper's business, not the
+ // sweeper's. Unknown: this frontend has no route and therefore
+ // observed nothing, which is the one outcome that must never be
+ // read as evidence. Resetting a counter on any of the three would
+ // free a reservation a live request is still holding.
rc.clearInFlightIdle(m.ID)
continue
}
@@ -626,8 +692,8 @@ const workerMissesBeforeReap = 2
// from ever being mistaken for a dead one.
//
// A worker that cannot be reached is skipped rather than treated as empty. A
-// messaging failure says nothing about the processes, and assuming the worst
-// would delete a whole node's rows on a transient NATS blip; the port probe
+// failure to route says nothing about the processes, and assuming the worst
+// would delete a whole node's rows every time a tunnel re-homed; the port probe
// remains as the fallback for those nodes.
func (rc *ReplicaReconciler) reconcileNodeProcesses(ctx context.Context) {
if rc.processLister == nil {
diff --git a/core/services/nodes/reconciler_busy_probe_test.go b/core/services/nodes/reconciler_busy_probe_test.go
index 9cc7b07f9923..7bf53ee57b0d 100644
--- a/core/services/nodes/reconciler_busy_probe_test.go
+++ b/core/services/nodes/reconciler_busy_probe_test.go
@@ -48,13 +48,13 @@ var _ = Describe("ReplicaReconciler — probe reaper vs busy backends", func() {
// seed inserts a stale loaded row so the probe pass picks it up.
seed := func(id string, inFlight int) {
Expect(db.Create(&NodeModel{
- ID: id,
- NodeID: node.ID,
- ModelName: id,
- Address: addr,
- State: "loaded",
- InFlight: inFlight,
- UpdatedAt: time.Now().Add(-5 * time.Minute),
+ ID: id,
+ NodeID: node.ID,
+ ModelName: id,
+ WorkerLocalAddress: addr,
+ State: "loaded",
+ InFlight: inFlight,
+ UpdatedAt: time.Now().Add(-5 * time.Minute),
}).Error).To(Succeed())
}
@@ -89,6 +89,48 @@ var _ = Describe("ReplicaReconciler — probe reaper vs busy backends", func() {
"a backend that accepted the connection but was mid-request must never be reaped")
})
+ It("never reaps a replica it could not probe at all", func() {
+ // ProbeUnknown is this FRONTEND saying it has no way to reach the
+ // worker, which is nothing at all about the backend. Folding it into
+ // ProbeUnreachable would empty the whole node_models table the moment
+ // the tunnel wiring was wrong, and the models would still be running.
+ seed("unknown-1", 0)
+ prober := &fakeProber{outcomes: map[string]ProbeOutcome{addr: ProbeUnknown}}
+ rc := newReconciler(prober)
+
+ for range probeFailuresBeforeReap * 3 {
+ rc.probeLoadedModels(context.Background())
+ makeStale("unknown-1")
+ }
+
+ var after NodeModel
+ Expect(db.First(&after, "id = ?", "unknown-1").Error).To(Succeed(),
+ "a replica this frontend could not reach must never be reaped")
+ })
+
+ It("does not let an unprobeable pass forgive a real failure streak", func() {
+ // The other half of the same rule. Clearing the streak on an outcome
+ // that observed nothing would let a flapping tunnel keep a genuinely
+ // dead backend in the table forever.
+ seed("mixed-1", 0)
+ prober := &fakeProber{outcomes: map[string]ProbeOutcome{addr: ProbeUnreachable}}
+ rc := newReconciler(prober)
+
+ for i := 1; i < probeFailuresBeforeReap; i++ {
+ rc.probeLoadedModels(context.Background())
+ makeStale("mixed-1")
+ }
+ prober.outcomes[addr] = ProbeUnknown
+ rc.probeLoadedModels(context.Background())
+ makeStale("mixed-1")
+
+ prober.outcomes[addr] = ProbeUnreachable
+ rc.probeLoadedModels(context.Background())
+ var after NodeModel
+ Expect(db.First(&after, "id = ?", "mixed-1").Error).To(MatchError(gorm.ErrRecordNotFound),
+ "an unprobeable pass must leave the streak untouched, not reset it")
+ })
+
It("reaps an unreachable replica even when in_flight leaked high", func() {
// in_flight has no decrement guarantee: a frontend that dies mid-request
// leaves the increment behind forever. Gating the reaper on it would
diff --git a/core/services/nodes/reconciler_inflight_leak_test.go b/core/services/nodes/reconciler_inflight_leak_test.go
index f3574fab7e0c..29fdadcbd98a 100644
--- a/core/services/nodes/reconciler_inflight_leak_test.go
+++ b/core/services/nodes/reconciler_inflight_leak_test.go
@@ -46,14 +46,14 @@ var _ = Describe("ReplicaReconciler — leaked in_flight sweeper", func() {
seed := func(id string, inFlight int, idleFor time.Duration) {
Expect(db.Create(&NodeModel{
- ID: id,
- NodeID: node.ID,
- ModelName: id,
- Address: addr,
- State: "loaded",
- InFlight: inFlight,
- LastUsed: time.Now().Add(-idleFor),
- UpdatedAt: time.Now(),
+ ID: id,
+ NodeID: node.ID,
+ ModelName: id,
+ WorkerLocalAddress: addr,
+ State: "loaded",
+ InFlight: inFlight,
+ LastUsed: time.Now().Add(-idleFor),
+ UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
diff --git a/core/services/nodes/reconciler_prober_test.go b/core/services/nodes/reconciler_prober_test.go
new file mode 100644
index 000000000000..b8cb80750987
--- /dev/null
+++ b/core/services/nodes/reconciler_prober_test.go
@@ -0,0 +1,214 @@
+// SPDX-License-Identifier: MIT
+
+package nodes
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
+)
+
+// refusalFromWorker builds the error a frontend actually holds after a worker
+// refused one of its streams.
+//
+// It goes over the WIRE rather than being handed the sentinel directly: the
+// refusal is written with the worker's own writer and read back with the
+// frontend's own reader, so a reason the protocol cannot carry, or a code
+// mapping that stopped round-tripping, reddens these specs instead of leaving
+// them asserting against a value production never produces.
+//
+// The wrap is chosen by cluster.IsWorkerAnswer, which is what
+// WorkerDialer.handshake does, so the spec exercises the real branch rather
+// than a transcription of it. That is not circular: the helper decides the
+// SHAPE of the error and the table below states the OUTCOME independently, so
+// moving a sentinel into or out of the predicate reddens the table. In
+// particular, adding ErrStreamNotServed to the predicate would strip its
+// umbrella here and turn its ProbeUnknown entry red, which is the property that
+// keeps a transient worker-side failure from reaping a live model.
+func refusalFromWorker(reason error) error {
+ GinkgoHelper()
+ var frame bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&frame, reason)).To(Succeed())
+ readBack := cluster.ReadStreamReply(&frame)
+ Expect(readBack).To(MatchError(reason), "the refusal must survive its own round trip")
+ Expect(readBack).ToNot(MatchError(cluster.ErrNoRoute))
+ if cluster.IsWorkerAnswer(readBack) {
+ return fmt.Errorf("opening %q on node %q: %w", "grpc", "node-1", readBack)
+ }
+ return fmt.Errorf("reaching node %q: %w: opening %q: %w", "node-1", cluster.ErrNoRoute, "grpc", readBack)
+}
+
+// proberFactory hands the prober one client, and records what it was asked for.
+type proberFactory struct {
+ client grpc.Backend
+ err error
+ asked []string
+}
+
+func (f *proberFactory) NewClientForNode(nodeID, address string, _ bool) (grpc.Backend, error) {
+ f.asked = append(f.asked, nodeID+"|"+address)
+ if f.err != nil {
+ return nil, f.err
+ }
+ return f.client, nil
+}
+
+var _ = Describe("the reconciler's gRPC model prober", func() {
+ // The two lines that decide whether a row survives, both previously
+ // untested. Everything else in the reaper is driven through fakeProber,
+ // which means the mapping from a real client to a ProbeOutcome had nothing
+ // holding it at all.
+ probe := func(f *proberFactory) ProbeOutcome {
+ GinkgoHelper()
+ return grpcModelProber{clients: f}.Probe(context.Background(), "node-1", "10.0.0.1:9001")
+ }
+
+ It("answers ProbeUnknown when no client can be built for the node", func() {
+ Expect(probe(&proberFactory{err: ErrNoWorkerDialer})).To(Equal(ProbeUnknown))
+ })
+
+ It("answers ProbeUnknown when the client was built and the tunnel dial failed", func() {
+ // The likelier half. ProbeUnreachable here would delete the row after
+ // probeFailuresBeforeReap passes of a peer link that was merely
+ // restarting, and the backend would still be running the model.
+ Expect(probe(&proberFactory{client: &fakeBackendClient{
+ healthy: false,
+ err: fmt.Errorf("rpc error: code = Unavailable"),
+ dialErr: fmt.Errorf("%w: %w", cluster.ErrNoRoute, cluster.ErrPeerUnreachable),
+ }})).To(Equal(ProbeUnknown))
+ })
+
+ It("asks for the client by NODE, not by address alone", func() {
+ f := &proberFactory{client: &fakeBackendClient{healthy: true}}
+ Expect(probe(f)).To(Equal(ProbeAlive))
+ Expect(f.asked).To(ContainElement("node-1|10.0.0.1:9001"))
+ })
+
+ It("answers ProbeAlive for a healthy backend it reached", func() {
+ Expect(probe(&proberFactory{client: &fakeBackendClient{healthy: true}})).To(Equal(ProbeAlive))
+ })
+
+ It("still answers ProbeUnreachable for a backend that answered unhealthy", func() {
+ // One of the two shapes a dead backend takes, and the easy one: the
+ // process is up enough to answer and reports itself unhealthy over a
+ // working transport. It is a ghost and its row should go.
+ //
+ // This spec used to be named for the property the table below holds,
+ // which it never tested: a backend process that DIED on a tunnelled
+ // worker does not answer at all, and what the frontend gets back is the
+ // worker's refusal, not an unhealthy reply.
+ Expect(probe(&proberFactory{client: &fakeBackendClient{healthy: false}})).To(Equal(ProbeUnreachable))
+ })
+
+ DescribeTable("answers ProbeUnreachable when the WORKER ITSELF refused the stream",
+ // The dominant shape of a dead backend since workers stopped listening,
+ // and the one that produced a permanently unreapable row. The worker is
+ // healthy, connected and answering; what it answers is that the stream
+ // cannot be served. That is evidence about the backend, so the reaper
+ // may act on it. Reported as ProbeUnknown instead, no reap path deleted
+ // the row, the replica slot never freed, and at the default
+ // MaxReplicasPerModel=1 the only remaining cleanup was LRU eviction of
+ // models that were working.
+ func(reason error) {
+ Expect(probe(&proberFactory{client: &fakeBackendClient{
+ healthy: false,
+ err: status.Error(codes.Unavailable, "connection error: transport"),
+ dialErr: refusalFromWorker(reason),
+ }})).To(Equal(ProbeUnreachable))
+ },
+ Entry("the worker could not reach the backend process", cluster.ErrStreamTargetUnavailable),
+ Entry("the worker does not serve gRPC streams at all", cluster.ErrStreamTagUnknown),
+ Entry("the worker rejected the stored address", cluster.ErrStreamRequestInvalid),
+ )
+
+ It("answers ProbeUnknown when the worker refused but said it learned nothing", func() {
+ // The fourth refusal, and the boundary that keeps the three above safe
+ // to act on. A worker answers with it for its OWN transient conditions:
+ // a request frame that never arrived in time, a stream whose deadline
+ // would not arm, a local dial that ended on the session going away.
+ // Those clear on a reconnect, so acting on them would convert
+ // peer-link congestion into a reaped row, and on the inference path
+ // into a model stopped across the fleet.
+ //
+ // This is not hypothetical: the header-timeout case USED to arrive as
+ // ErrStreamRequestInvalid, which the table above reaps on.
+ Expect(probe(&proberFactory{client: &fakeBackendClient{
+ healthy: false,
+ err: status.Error(codes.Unavailable, "connection error: transport"),
+ dialErr: refusalFromWorker(cluster.ErrStreamNotServed),
+ }})).To(Equal(ProbeUnknown))
+ })
+
+ It("answers ProbeUnknown for a refusal code this frontend does not recognise", func() {
+ // The other direction, and the boundary of the exemption above. A
+ // newer worker's vocabulary must not be read as evidence about a
+ // backend: ReadStreamReply returns an unrecognised code as a plain
+ // error, WorkerDialer puts the no-route umbrella on it, and the row
+ // survives. Guessing wrong here costs a retry; guessing wrong the other
+ // way costs a reaped replica.
+ unknownCode := fmt.Errorf("reaching node %q: %w: opening %q: tunnel stream refused with unrecognised code %q: %s",
+ "node-1", cluster.ErrNoRoute, "grpc", "quiesced", "this worker is draining")
+ Expect(probe(&proberFactory{client: &fakeBackendClient{
+ healthy: false,
+ err: status.Error(codes.Unavailable, "connection error: transport"),
+ dialErr: unknownCode,
+ }})).To(Equal(ProbeUnknown))
+ })
+
+ It("answers ProbeUnknown when the tunnel broke while reading the worker's reply", func() {
+ // The condition a refusal is most easily confused with, kept apart on
+ // purpose: a read failure is the tunnel breaking, not the worker
+ // speaking, and it says nothing about the backend.
+ Expect(probe(&proberFactory{client: &fakeBackendClient{
+ healthy: false,
+ err: status.Error(codes.Unavailable, "connection error: transport"),
+ dialErr: fmt.Errorf("reaching node %q: %w: opening %q: reading a tunnel stream reply: %w",
+ "node-1", cluster.ErrNoRoute, "grpc", io.ErrUnexpectedEOF),
+ }})).To(Equal(ProbeUnknown))
+ })
+
+ It("does not report a transport that recovered", func() {
+ // LastDialError is cleared by a successful dial, so a client that
+ // failed once and then reconnected must not keep reading as
+ // unroutable; otherwise a row could never be reaped again after one
+ // blip on that client.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = listener.Close() })
+
+ attempt := 0
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return func(ctx context.Context, _ string) (net.Conn, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("first dial fails")
+ }
+ return d.DialContext(ctx, "tcp", listener.Addr().String())
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ client, err := f.NewClientForNode("node-1", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+
+ _, _ = client.HealthCheck(context.Background())
+ Expect(unroutable(client)).ToNot(BeNil())
+
+ // gRPC re-dials on the next call; the listener now accepts.
+ Eventually(func() error {
+ _, _ = client.HealthCheck(context.Background())
+ return unroutable(client)
+ }, "20s").Should(BeNil())
+ })
+})
diff --git a/core/services/nodes/reconciler_test.go b/core/services/nodes/reconciler_test.go
index 049fb94418de..64171d1859d3 100644
--- a/core/services/nodes/reconciler_test.go
+++ b/core/services/nodes/reconciler_test.go
@@ -9,8 +9,10 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
"github.com/mudler/LocalAI/core/services/testutil"
+ "github.com/mudler/LocalAI/core/services/workerctl"
"gorm.io/gorm"
)
@@ -740,7 +742,7 @@ type fakeProber struct {
calls int
}
-func (f *fakeProber) Probe(_ context.Context, address string) ProbeOutcome {
+func (f *fakeProber) Probe(_ context.Context, _, address string) ProbeOutcome {
f.calls++
if f.outcomes == nil {
return ProbeUnreachable
@@ -770,20 +772,20 @@ var _ = Describe("ReplicaReconciler — state reconciliation", func() {
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Two loaded models — one stale (will probe), one fresh (skipped).
stale := &NodeModel{
- ID: "stale-1",
- NodeID: node.ID,
- ModelName: "stale-model",
- Address: "10.0.0.1:12345",
- State: "loaded",
- UpdatedAt: time.Now().Add(-5 * time.Minute),
+ ID: "stale-1",
+ NodeID: node.ID,
+ ModelName: "stale-model",
+ WorkerLocalAddress: "10.0.0.1:12345",
+ State: "loaded",
+ UpdatedAt: time.Now().Add(-5 * time.Minute),
}
fresh := &NodeModel{
- ID: "fresh-1",
- NodeID: node.ID,
- ModelName: "fresh-model",
- Address: "10.0.0.1:54321",
- State: "loaded",
- UpdatedAt: time.Now(), // within probeStaleAfter
+ ID: "fresh-1",
+ NodeID: node.ID,
+ ModelName: "fresh-model",
+ WorkerLocalAddress: "10.0.0.1:54321",
+ State: "loaded",
+ UpdatedAt: time.Now(), // within probeStaleAfter
}
Expect(db.Create(stale).Error).To(Succeed())
Expect(db.Create(fresh).Error).To(Succeed())
@@ -815,12 +817,12 @@ var _ = Describe("ReplicaReconciler — state reconciliation", func() {
node := &BackendNode{Name: "n1", NodeType: NodeTypeBackend, Address: "10.0.0.1:50051"}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stale := &NodeModel{
- ID: "stale-2",
- NodeID: node.ID,
- ModelName: "alive-model",
- Address: "10.0.0.1:12345",
- State: "loaded",
- UpdatedAt: time.Now().Add(-5 * time.Minute),
+ ID: "stale-2",
+ NodeID: node.ID,
+ ModelName: "alive-model",
+ WorkerLocalAddress: "10.0.0.1:12345",
+ State: "loaded",
+ UpdatedAt: time.Now().Add(-5 * time.Minute),
}
Expect(db.Create(stale).Error).To(Succeed())
@@ -872,6 +874,118 @@ var _ = Describe("ReplicaReconciler — state reconciliation", func() {
})
})
+ // The pending-op drain runs the SAME rolling-update fallback the admin
+ // path does, and that fallback re-fires a DESTRUCTIVE force-reinstall.
+ // Only the worker's own "I do not serve that verb" may trigger it. A
+ // worker this frontend could not reach has said nothing about the verb,
+ // and a BACKGROUND drain that force-reinstalls on silence is worse than
+ // the admin path doing it: nobody is watching, it retries on every tick,
+ // and a frontend replica that has just lost its tunnels would reinstall
+ // every queued backend on the fleet.
+ //
+ // The admin path's negative direction is pinned in
+ // managers_distributed_test.go; this one is the second call site of the
+ // same rule and was unpinned, so the condition here could be widened to
+ // any error with the whole suite still green.
+ Describe("draining a pending backend upgrade", func() {
+ var (
+ workers *scriptedControlWorkers
+ node *BackendNode
+ rc *ReplicaReconciler
+ )
+
+ BeforeEach(func() {
+ workers = newScriptedControlWorkers()
+ node = &BackendNode{Name: "worker-drain", NodeType: NodeTypeBackend, Address: "10.0.0.9:50051"}
+ Expect(registry.Register(context.Background(), node, true)).To(Succeed())
+ Expect(registry.UpsertPendingBackendOp(context.Background(), node.ID, "vllm", OpBackendUpgrade, []byte("[]"))).To(Succeed())
+ rc = NewReplicaReconciler(ReplicaReconcilerOptions{
+ Registry: registry,
+ Scheduler: &fakeScheduler{},
+ DB: db,
+ Adapter: NewRemoteUnloaderAdapter(registry, nil, workers.controlClient(), time.Minute, time.Minute),
+ })
+ })
+
+ queuedOps := func() []PendingBackendOp {
+ var rows []PendingBackendOp
+ Expect(db.Find(&rows).Error).To(Succeed())
+ return rows
+ }
+
+ It("falls back to the legacy force install when the worker does not serve the upgrade verb", func() {
+ workers.scriptUnsupported(controlKey(node.ID, workerctl.PathBackendUpgrade))
+ workers.scriptReplyMatching(controlKey(node.ID, workerctl.PathBackendInstall),
+ func(req messaging.BackendInstallRequest) bool { return req.Force },
+ messaging.BackendInstallReply{Success: true})
+
+ rc.drainPendingBackendOps(context.Background())
+
+ Expect(workers.callSubjects()).To(Equal([]string{
+ controlKey(node.ID, workerctl.PathBackendUpgrade),
+ controlKey(node.ID, workerctl.PathBackendInstall),
+ }))
+ Expect(queuedOps()).To(BeEmpty(), "an op the fallback converged is drained")
+ })
+
+ // The negative direction, arranged so it cannot pass vacuously: the
+ // force install IS scripted and IS reachable here, so a fallback that
+ // fired would show up as a call AND as a drained row. What the worker
+ // fails to do is SERVE the upgrade verb, which is a 5xx and not the
+ // 404 that means it lacks it.
+ It("does NOT fall back to the legacy force install when the worker failed to serve the upgrade", func() {
+ workers.scriptReplyMatching(controlKey(node.ID, workerctl.PathBackendInstall),
+ func(req messaging.BackendInstallRequest) bool { return req.Force },
+ messaging.BackendInstallReply{Success: true})
+
+ rc.drainPendingBackendOps(context.Background())
+
+ Expect(workers.callSubjects()).ToNot(ContainElement(controlKey(node.ID, workerctl.PathBackendInstall)),
+ "an upgrade the worker failed to serve must not re-fire a force-reinstall")
+ rows := queuedOps()
+ Expect(rows).To(HaveLen(1), "the op keeps its backoff and is retried, not converged")
+ Expect(rows[0].Attempts).To(Equal(1))
+ })
+
+ // A failed op must not DEMOTE the node, and this is the second of the
+ // rule's three call sites. Marking unhealthy here takes the node out of
+ // ListDuePendingBackendOps and out of scheduling at the same time, so a
+ // frontend replica that has just lost its tunnels would evict every node
+ // with a queued op, fleet-wide, for a reason that is about the frontend.
+ //
+ // The surviving row is the negative control: it witnesses that the drain
+ // actually ran and actually failed, so "still healthy" cannot pass by
+ // nothing having happened.
+ It("does NOT demote a node whose queued op it could not route", func() {
+ workers.scriptUnroutable(node.ID)
+
+ rc.drainPendingBackendOps(context.Background())
+
+ rows := queuedOps()
+ Expect(rows).To(HaveLen(1), "the drain must have run and failed")
+ Expect(rows[0].Attempts).To(Equal(1))
+
+ after, err := registry.Get(context.Background(), node.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(after.Status).To(Equal(StatusHealthy))
+ })
+
+ // The same rule for the other non-answer: no route at all. The install
+ // is unreachable too here, so the witness is the error the drain
+ // RECORDED, which names the verb that actually failed.
+ It("records the upgrade's own failure when the worker could not be routed to", func() {
+ workers.scriptUnroutable(node.ID)
+
+ rc.drainPendingBackendOps(context.Background())
+
+ rows := queuedOps()
+ Expect(rows).To(HaveLen(1))
+ Expect(rows[0].LastError).To(ContainSubstring(workerctl.PathBackendUpgrade))
+ Expect(rows[0].LastError).ToNot(ContainSubstring(workerctl.PathBackendInstall),
+ "a fallback that fired would have replaced the upgrade's error with the install's")
+ })
+ })
+
Describe("NewNodeRegistry malformed-row pruning", func() {
It("drops queue rows for agent nodes and non-existent nodes on startup", func() {
agent := &BackendNode{Name: "agent-1", NodeType: NodeTypeAgent, Address: "x"}
diff --git a/core/services/nodes/reconciler_worker_processes_test.go b/core/services/nodes/reconciler_worker_processes_test.go
index 8fd848b1d2de..84459690f066 100644
--- a/core/services/nodes/reconciler_worker_processes_test.go
+++ b/core/services/nodes/reconciler_worker_processes_test.go
@@ -54,13 +54,13 @@ var _ = Describe("ReplicaReconciler — reconcile against worker processes", fun
seed := func(id, modelName string, replica int, age time.Duration) {
Expect(db.Create(&NodeModel{
- ID: id,
- NodeID: node.ID,
- ModelName: modelName,
- ReplicaIndex: replica,
- Address: "10.0.0.1:12345",
- State: "loaded",
- UpdatedAt: time.Now().Add(-age),
+ ID: id,
+ NodeID: node.ID,
+ ModelName: modelName,
+ ReplicaIndex: replica,
+ WorkerLocalAddress: "10.0.0.1:12345",
+ State: "loaded",
+ UpdatedAt: time.Now().Add(-age),
}).Error).To(Succeed())
}
diff --git a/core/services/nodes/registry.go b/core/services/nodes/registry.go
index 4b4f7f1c8b32..2a08c9007161 100644
--- a/core/services/nodes/registry.go
+++ b/core/services/nodes/registry.go
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/advisorylock"
+ "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/xlog"
@@ -20,15 +21,49 @@ import (
// Workers are generic — they don't have a fixed backend type.
// The SmartRouter dynamically installs backends via NATS backend.install events.
type BackendNode struct {
- ID string `gorm:"primaryKey;size:36" json:"id"`
- Name string `gorm:"uniqueIndex;size:255" json:"name"`
- NodeType string `gorm:"size:32;default:backend" json:"node_type"` // backend, agent
- Address string `gorm:"size:255" json:"address"` // host:port for gRPC
- HTTPAddress string `gorm:"size:255" json:"http_address"` // host:port for HTTP file transfer
- Status string `gorm:"size:32;default:registering" json:"status"` // registering, healthy, unhealthy, draining, pending
- TokenHash string `gorm:"size:64" json:"-"` // SHA-256 of registration token
- TotalVRAM uint64 `gorm:"column:total_vram" json:"total_vram"` // Total GPU VRAM in bytes
- AvailableVRAM uint64 `gorm:"column:available_vram" json:"available_vram"` // Available GPU VRAM in bytes
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ Name string `gorm:"uniqueIndex;size:255" json:"name"`
+ NodeType string `gorm:"size:32;default:backend" json:"node_type"` // backend, agent
+ // Address and HTTPAddress are what a PRE-TUNNEL worker advertised as its
+ // inbound gRPC and HTTP endpoints. Nothing dials them and nothing reads
+ // them to make a decision: a worker holds one outbound tunnel and every
+ // protocol the frontend speaks to it travels on that.
+ //
+ // A worker running this release sends neither, and Register force-clears
+ // both ON RE-REGISTRATION so an upgraded worker's stale advertisement does
+ // not survive its own upgrade and keep showing in the API and the UI. A
+ // first registration writes what it was given, which is how a spec that
+ // builds a node with an address still gets one. They are kept as
+ // columns rather than dropped only because dropping them is a wide,
+ // mechanical change across the fleet of specs that build a BackendNode,
+ // and they are inert either way.
+ Address string `gorm:"size:255" json:"address"`
+ HTTPAddress string `gorm:"size:255" json:"http_address"`
+ Status string `gorm:"size:32;default:registering" json:"status"` // registering, healthy, unhealthy, draining, pending
+ TokenHash string `gorm:"size:64" json:"-"` // SHA-256 of registration token
+ // TunnelTokenHash is the SHA-256 of this node's OWN tunnel credential, the
+ // one it presents at GET /api/cluster/connect. It is not the registration
+ // token: registration mints a fresh random secret per node, returns the
+ // plaintext once, and stores only this hash, so a leaked registration token
+ // no longer opens a tunnel for every node whose ID an attacker can read.
+ //
+ // Stated exactly, because the useful half of the claim is the half that is
+ // still true. A leaked registration token no longer lets its holder BE a
+ // worker; it still lets its holder REACH every worker, because
+ // GET /api/cluster/peer authenticates with the shared cluster token and
+ // takes its ?id= on trust (see core/http/endpoints/cluster/peer.go), which
+ // also puts the ~31 GiB per-session peer receive window inside reach of
+ // anything holding it. Per replica-to-replica credentials are a named
+ // phase-3 item, not something this column already delivers.
+ //
+ // Empty means no tunnel credential has been minted for this node yet, which
+ // is what a node registered by an older LocalAI looks like. Such a node
+ // cannot tunnel until it registers again. That is deliberate: the column
+ // cannot be back-filled, because the plaintext exists only in the response
+ // that minted it.
+ TunnelTokenHash string `gorm:"size:64" json:"-"`
+ TotalVRAM uint64 `gorm:"column:total_vram" json:"total_vram"` // Total GPU VRAM in bytes
+ AvailableVRAM uint64 `gorm:"column:available_vram" json:"available_vram"` // Available GPU VRAM in bytes
// ReservedVRAM is a soft, in-tick reservation deducted by the scheduler when
// it picks this node to load a model. Workers reset it back to 0 on each
// heartbeat (the worker is the source of truth for actual free VRAM); the
@@ -120,14 +155,28 @@ const (
//
// Multiple replicas of the same model on the same node are allowed; each
// replica has its own ReplicaIndex (0..MaxReplicasPerModel-1), its own
-// gRPC Address (each replica is a separate worker process on its own port),
-// and its own InFlight counter.
+// WorkerLocalAddress (each replica is a separate worker process on its own
+// port), and its own InFlight counter.
type NodeModel struct {
- ID string `gorm:"primaryKey;size:36" json:"id"`
- NodeID string `gorm:"index;size:36" json:"node_id"`
- ModelName string `gorm:"index;size:255" json:"model_name"`
- ReplicaIndex int `gorm:"column:replica_index;default:0;index" json:"replica_index"`
- Address string `gorm:"size:255" json:"address"` // gRPC address for this replica's backend process
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ NodeID string `gorm:"index;size:36" json:"node_id"`
+ ModelName string `gorm:"index;size:255" json:"model_name"`
+ ReplicaIndex int `gorm:"column:replica_index;default:0;index" json:"replica_index"`
+ // WorkerLocalAddress is where this replica's backend process listens ON
+ // ITS WORKER. It is a loopback address and it is not dialable from here.
+ //
+ // It survived the removal of every worker address for one reason: the
+ // frontend still has to say WHICH backend process on a worker it means,
+ // and the port in this string is how it says it. It travels as the target
+ // of a stream on that worker's tunnel; the worker reads the port, checks
+ // it against its own allocator range, and dials its own loopback. Nothing
+ // in the frontend may treat it as a dial target, which is why it is not
+ // called Address any more: the old name is what a reader had to already
+ // know the design to interpret correctly.
+ //
+ // The column and the json key stay "address" so no migration and no API
+ // break rides along with the rename.
+ WorkerLocalAddress string `gorm:"column:address;size:255" json:"address"`
State string `gorm:"size:32;default:idle" json:"state"` // staging, loading, loaded, unloading, idle
InFlight int `json:"in_flight"` // number of active requests on this replica
LastUsed time.Time `json:"last_used"`
@@ -442,7 +491,14 @@ func (r *NodeRegistry) nodeModelNames(ctx context.Context, db *gorm.DB, nodeID s
// when multiple instances (frontend + workers) start at the same time.
func NewNodeRegistry(db *gorm.DB) (*NodeRegistry, error) {
if err := advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error {
- return db.AutoMigrate(&BackendNode{}, &NodeModel{}, &NodeLabel{}, &ModelSchedulingConfig{}, &PendingBackendOp{}, &ModelLoadInfo{}, &ModelLoadJob{}, &ModelConfigState{})
+ if err := db.AutoMigrate(&BackendNode{}, &NodeModel{}, &NodeLabel{}, &ModelSchedulingConfig{}, &PendingBackendOp{}, &ModelLoadInfo{}, &ModelLoadJob{}, &ModelConfigState{}); err != nil {
+ return err
+ }
+ // The cluster package owns its own tables AND the sequence its
+ // ownership fence draws epochs from, which AutoMigrate cannot express.
+ // It runs under this same lock so concurrently starting replicas do not
+ // race on the DDL.
+ return cluster.Migrate(context.Background(), db)
}); err != nil {
return nil, fmt.Errorf("migrating node tables: %w", err)
}
@@ -575,6 +631,15 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
return fmt.Errorf("clearing worker VRAM budget for node %s: %w", node.Name, err)
}
}
+ // Force-clear the advertised addresses. Updates(struct) zero-skips, so a
+ // node that registered before workers stopped advertising would keep the
+ // host:port it reported then for the rest of its life, and the API and
+ // the Nodes page would keep showing an endpoint that nothing dials and
+ // that may not even exist any more.
+ if err := r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", node.ID).
+ Updates(map[string]any{"address": node.Address, "http_address": node.HTTPAddress}).Error; err != nil {
+ return fmt.Errorf("clearing the advertised addresses for node %s: %w", node.Name, err)
+ }
// Force-write the disk columns. Updates(struct) above zero-skips, and a
// worker whose models filesystem is 100% full re-registers with
// available_disk == 0 — the single most important reading there is.
@@ -637,7 +702,7 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
return fmt.Errorf("looking up node %s: %w", node.Name, err)
}
- xlog.Info("Node registered", "name", node.Name, "address", node.Address, "status", node.Status)
+ xlog.Info("Node registered", "name", node.Name, "id", node.ID, "status", node.Status)
// Cluster capacity may have changed: a new healthy node, a returning
// node, or one with different MaxReplicasPerModel. Wake any configs the
// reconciler put in cooldown — the next tick will re-flag if still
@@ -656,6 +721,24 @@ func (r *NodeRegistry) UpdateAuthRefs(ctx context.Context, nodeID, authUserID, a
}).Error
}
+// SetTunnelTokenHash records the hash of a freshly minted tunnel credential for
+// a node, replacing whatever was there.
+//
+// Replacing is the whole design and not an accident of the implementation. Only
+// the hash is stored, so a re-registering worker cannot be told the secret it
+// already has, and the alternative to rotating would be storing the plaintext.
+// The live tunnel of a worker that re-registers is unaffected, because the
+// credential is checked when a tunnel is DIALLED and never again; what changes
+// is which secret its next reconnect must present, and the worker learns that
+// in the same response that rotated it.
+func (r *NodeRegistry) SetTunnelTokenHash(ctx context.Context, nodeID, hash string) error {
+ // Not Updates(struct): a struct update zero-skips, so this could never
+ // clear the column, and a caller that means to clear it would be silently
+ // ignored.
+ return r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", nodeID).
+ Update("tunnel_token_hash", hash).Error
+}
+
// ApproveNode sets a pending node's status to healthy.
func (r *NodeRegistry) ApproveNode(ctx context.Context, nodeID string) error {
result := r.db.WithContext(ctx).Model(&BackendNode{}).
@@ -1027,13 +1110,19 @@ func (r *NodeRegistry) GetByName(ctx context.Context, name string) (*BackendNode
return &node, nil
}
-// MarkUnhealthy sets a node status to unhealthy. Deliberately status-only:
-// callers fire this on transient triggers (a single nats.ErrNoResponders from
-// managers_distributed / reconciler) where the next heartbeat is expected to
-// flip the node back to healthy, and cascade-deleting node_models here would
-// force a full model reload on every brief NATS hiccup. Stale rows are reaped
-// by the per-model health probe (on by default; see HealthMonitor) and by
-// MarkOffline when the heartbeat really has gone away.
+// MarkUnhealthy sets a node status to unhealthy. Deliberately status-only: it
+// stops placement, it does not delete a row, and that is what keeps it inside
+// what the scheduler's one demotion trigger licenses.
+//
+// That trigger is now a single fact rather than a class of transient failures:
+// the scheduler demotes only a node cluster.Presence reports as GONE, meaning
+// no live replica holds its tunnel and its departure has outlived the reconnect
+// grace (see SmartRouter.nodeMayTakeWork). A failed control RPC no longer
+// reaches here at all. Cascade-deleting node_models here would still be wrong
+// even so: the node may be re-dialling, and reloading every model it held is
+// exactly the fleet-wide eviction the tunnel work exists to prevent. Stale rows
+// are reaped by the per-model health probe (on by default; see HealthMonitor)
+// and by MarkOffline when the heartbeat really has gone away.
func (r *NodeRegistry) MarkUnhealthy(ctx context.Context, nodeID string) error {
return r.setStatus(ctx, nodeID, StatusUnhealthy)
}
@@ -1414,7 +1503,7 @@ func (r *NodeRegistry) ClaimModelCleanupRetries(ctx context.Context, now, leaseU
func (r *NodeRegistry) RemoveClaimedModelCleanup(ctx context.Context, replica NodeModel) (bool, error) {
result := r.db.WithContext(ctx).
Where("id = ? AND node_id = ? AND model_name = ? AND replica_index = ? AND state = ? AND address = ? AND config_revision = ?",
- replica.ID, replica.NodeID, replica.ModelName, replica.ReplicaIndex, "unloading", replica.Address, replica.ConfigRevision).
+ replica.ID, replica.NodeID, replica.ModelName, replica.ReplicaIndex, "unloading", replica.WorkerLocalAddress, replica.ConfigRevision).
Delete(&NodeModel{})
if result.Error != nil {
return false, result.Error
@@ -2496,8 +2585,8 @@ func (r *NodeRegistry) DeleteStalePendingBackendOps(ctx context.Context, grace t
cutoff := time.Now().Add(-grace)
// Draining nodes are cleared immediately (admin action; model rows already
// purged). Offline AND unhealthy nodes are cleared only once their heartbeat
- // is older than the grace window: a node marked unhealthy on a NATS
- // ErrNoResponders never transitions to offline (health.go skips re-marking
+ // is older than the grace window: a node the scheduler demoted for a
+ // departed tunnel never transitions to offline (health.go skips re-marking
// it), so without including unhealthy here its ops would leak exactly like
// the offline case. A node with a fresh heartbeat (last_heartbeat > cutoff)
// is recovering and keeps its op for retry.
diff --git a/core/services/nodes/registry_test.go b/core/services/nodes/registry_test.go
index c240f2f015ef..a75fd7e73ba3 100644
--- a/core/services/nodes/registry_test.go
+++ b/core/services/nodes/registry_test.go
@@ -56,6 +56,32 @@ var _ = Describe("NodeRegistry", func() {
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(node.Status).To(Equal(StatusHealthy))
})
+
+ It("clears the advertised addresses a pre-tunnel worker left behind", func() {
+ // The struct update zero-skips, so an upgraded worker that stops
+ // sending an address would otherwise keep the one it reported before
+ // the upgrade for the rest of the row's life, and the API and the
+ // Nodes page would keep offering an endpoint nothing dials and that
+ // may not exist any more.
+ ctx := context.Background()
+ legacy := makeNode("worker-upgraded", "10.0.0.8:50051", 8_000_000_000)
+ legacy.HTTPAddress = "10.0.0.8:50050"
+ Expect(registry.Register(ctx, legacy, true)).To(Succeed())
+
+ stored, err := registry.GetByName(ctx, "worker-upgraded")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored.Address).To(Equal("10.0.0.8:50051"), "precondition: the old row carries the advertisement")
+
+ // Same name, no address: what the upgraded worker sends.
+ upgraded := makeNode("worker-upgraded", "", 8_000_000_000)
+ Expect(registry.Register(ctx, upgraded, true)).To(Succeed())
+
+ stored, err = registry.GetByName(ctx, "worker-upgraded")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(stored.ID).To(Equal(legacy.ID), "precondition: this is the same row, not a new one")
+ Expect(stored.Address).To(BeEmpty())
+ Expect(stored.HTTPAddress).To(BeEmpty())
+ })
})
Describe("Re-registration", func() {
@@ -167,7 +193,7 @@ var _ = Describe("NodeRegistry", func() {
Expect(err).ToNot(HaveOccurred())
Expect(nm2.ID).To(Equal(nm1.ID), "ID should remain stable across SetNodeModel calls")
- Expect(nm2.Address).To(Equal("10.0.0.99:50053"), "Address should be updated")
+ Expect(nm2.WorkerLocalAddress).To(Equal("10.0.0.99:50053"), "Address should be updated")
})
})
@@ -983,8 +1009,8 @@ var _ = Describe("NodeRegistry", func() {
for _, m := range models {
byIdx[m.ReplicaIndex] = m
}
- Expect(byIdx[0].Address).To(Equal("127.0.0.1:50100"))
- Expect(byIdx[1].Address).To(Equal("127.0.0.1:50101"))
+ Expect(byIdx[0].WorkerLocalAddress).To(Equal("127.0.0.1:50100"))
+ Expect(byIdx[1].WorkerLocalAddress).To(Equal("127.0.0.1:50101"))
Expect(byIdx[0].ID).ToNot(Equal(byIdx[1].ID))
})
@@ -1001,7 +1027,7 @@ var _ = Describe("NodeRegistry", func() {
survivor, err := registry.GetNodeModel(context.Background(), node.ID, "kept-model", 1)
Expect(err).ToNot(HaveOccurred())
Expect(survivor).ToNot(BeNil())
- Expect(survivor.Address).To(Equal("127.0.0.1:50111"))
+ Expect(survivor.WorkerLocalAddress).To(Equal("127.0.0.1:50111"))
// Replica 0 is gone
_, err = registry.GetNodeModel(context.Background(), node.ID, "kept-model", 0)
@@ -1744,7 +1770,7 @@ var _ = Describe("NodeRegistry", func() {
Expect(err).ToNot(HaveOccurred())
Expect(models).To(ConsistOf(And(
HaveField("ConfigRevision", "rev-new"),
- HaveField("Address", "10.0.2.20:7001"),
+ HaveField("WorkerLocalAddress", "10.0.2.20:7001"),
HaveField("State", "loaded"),
)))
})
diff --git a/core/services/nodes/registry_wire_test.go b/core/services/nodes/registry_wire_test.go
new file mode 100644
index 000000000000..34eec32e44df
--- /dev/null
+++ b/core/services/nodes/registry_wire_test.go
@@ -0,0 +1,39 @@
+package nodes
+
+import (
+ "encoding/json"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// NodeModel.WorkerLocalAddress was called Address. The Go field was renamed so
+// no reader takes it for a frontend-dialable endpoint; the json key was kept so
+// no API consumer breaks, and the gorm column was kept so no migration is
+// needed.
+//
+// The column half is enforced by the raw-SQL fragments in this package, which
+// fail loudly against a renamed column. The json half had nothing enforcing it:
+// renaming only the tag left this package, messaging and endpoints/localai all
+// green. These specs are that half.
+var _ = Describe("NodeModel wire format", func() {
+ It("serves the address under the key API consumers already read", func() {
+ out, err := json.Marshal(NodeModel{
+ NodeID: "node-1", ModelName: "m", ReplicaIndex: 1,
+ WorkerLocalAddress: "127.0.0.1:50052",
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var raw map[string]any
+ Expect(json.Unmarshal(out, &raw)).To(Succeed())
+ Expect(raw).To(HaveKeyWithValue("address", "127.0.0.1:50052"))
+ Expect(raw).ToNot(HaveKey("worker_local_address"),
+ "GET /api/nodes/{id}/models and /api/nodes/models both serve this struct verbatim")
+ })
+
+ It("round-trips a body written against the documented key", func() {
+ var nm NodeModel
+ Expect(json.Unmarshal([]byte(`{"node_id":"node-1","model_name":"m","address":"127.0.0.1:50052"}`), &nm)).To(Succeed())
+ Expect(nm.WorkerLocalAddress).To(Equal("127.0.0.1:50052"))
+ })
+})
diff --git a/core/services/nodes/revision_eligibility_test.go b/core/services/nodes/revision_eligibility_test.go
index 96ea5010b704..d5fe2aa60b05 100644
--- a/core/services/nodes/revision_eligibility_test.go
+++ b/core/services/nodes/revision_eligibility_test.go
@@ -54,7 +54,7 @@ var _ = Describe("revision eligibility consumers", func() {
}
Expect(db.Create(&NodeModel{
ID: kind, NodeID: node.ID, ModelName: modelName, ReplicaIndex: i,
- Address: kind, State: state, ConfigRevision: revision, LastUsed: time.Now().Add(time.Duration(i) * time.Minute),
+ WorkerLocalAddress: kind, State: state, ConfigRevision: revision, LastUsed: time.Now().Add(time.Duration(i) * time.Minute),
UpdatedAt: time.Now().Add(-time.Hour),
}).Error).To(Succeed())
}
@@ -148,7 +148,7 @@ var _ = Describe("revision eligibility consumers", func() {
Expect(db.Model(&NodeModel{}).Where("id = ?", "mismatch").Update("replica_index", 9).Error).To(Succeed())
Expect(db.Create(&NodeModel{
ID: "current-extra", NodeID: nodes["current"].ID, ModelName: modelName,
- ReplicaIndex: 4, Address: "current-extra", State: "loaded",
+ ReplicaIndex: 4, WorkerLocalAddress: "current-extra", State: "loaded",
ConfigRevision: "current", LastUsed: time.Now().Add(-time.Hour),
}).Error).To(Succeed())
@@ -214,7 +214,7 @@ var _ = Describe("revision eligibility consumers", func() {
// the minimum and the oldest eligible current row may be selected.
Expect(db.Create(&NodeModel{
ID: "current-extra", NodeID: nodes["current"].ID, ModelName: modelName,
- ReplicaIndex: 4, Address: "current-extra", State: "loaded",
+ ReplicaIndex: 4, WorkerLocalAddress: "current-extra", State: "loaded",
ConfigRevision: "current", LastUsed: time.Now().Add(time.Minute),
}).Error).To(Succeed())
unloader := &fakeUnloader{}
@@ -264,7 +264,7 @@ var _ = Describe("revision eligibility consumers", func() {
type recordingEligibilityProber struct{ addresses []string }
-func (p *recordingEligibilityProber) Probe(_ context.Context, address string) ProbeOutcome {
+func (p *recordingEligibilityProber) Probe(_ context.Context, _, address string) ProbeOutcome {
p.addresses = append(p.addresses, address)
return ProbeAlive
}
diff --git a/core/services/nodes/router.go b/core/services/nodes/router.go
index d094d0f2076e..eb1a01c2caa5 100644
--- a/core/services/nodes/router.go
+++ b/core/services/nodes/router.go
@@ -40,6 +40,19 @@ var companionSuffixes = map[string][]string{
// Passing them at construction time eliminates data races from post-creation setters.
type SmartRouterOptions struct {
Unloader NodeCommandSender
+ // Presence answers whether a worker's tunnel is held, lost inside the
+ // reconnect grace, lost past it, or unknown. It is the ONLY thing the
+ // scheduler reads absence from; nil disables the check entirely and every
+ // node is treated as present, which is what a deployment with no cluster
+ // registry gets. See nodeMayTakeWork.
+ Presence NodePresenceReader
+ // ReconnectGrace is how long a lost tunnel is read as reconnecting rather
+ // than gone. It is the operator's trade rather than this package's, so
+ // callers pass config.DistributedConfig.ReconnectGraceOrDefault(). Zero
+ // selects the same default: a caller that wires Presence and forgets the
+ // grace gets the documented window instead of a zero one, which would
+ // condemn every worker the instant its tunnel dropped.
+ ReconnectGrace time.Duration
// ModelCleanup performs acknowledged exact-process cleanup when a load
// finishes after its configuration revision became stale.
ModelCleanup *ModelCleanupService
@@ -152,8 +165,12 @@ func ModelLoadCeilingFor(installTimeout, loadTimeout time.Duration) time.Duratio
// SmartRouter routes inference requests to the best available backend node.
// It uses the ModelRouter interface (backed by NodeRegistry in production) for routing decisions.
type SmartRouter struct {
- registry ModelRouter
- unloader NodeCommandSender // optional, for NATS-driven load/unload
+ registry ModelRouter
+ unloader NodeCommandSender // optional, for control-plane load/unload
+ // presence is the scheduler's only source of absence, and reconnectGrace
+ // the window it measures a departure against. See nodeMayTakeWork.
+ presence NodePresenceReader
+ reconnectGrace time.Duration
modelCleanup *ModelCleanupService
fileStager FileStager // optional, for distributed file transfer
galleriesJSON string // backend gallery config for dynamic installation
@@ -234,9 +251,18 @@ func NewSmartRouter(registry ModelRouter, opts SmartRouterOptions) *SmartRouter
if diskHeadroom == nil {
diskHeadroom = func() bool { return true }
}
+ // Zero means the caller wired a presence reader without an explicit grace.
+ // Defaulted here rather than left at zero, because a zero window makes
+ // every departure instantly a verdict.
+ grace := opts.ReconnectGrace
+ if grace <= 0 {
+ grace = config.DefaultWorkerReconnectGrace
+ }
return &SmartRouter{
registry: registry,
unloader: opts.Unloader,
+ presence: opts.Presence,
+ reconnectGrace: grace,
modelCleanup: opts.ModelCleanup,
fileStager: opts.FileStager,
galleriesJSON: opts.GalleriesJSON,
@@ -393,7 +419,10 @@ func (r *SmartRouter) scheduleAndLoad(ctx context.Context, backendType, tracking
}
}
- client := r.buildClientForAddr(node, backendAddr, parallel)
+ client, err := r.buildClientForAddr(node, backendAddr, parallel)
+ if err != nil {
+ return nil, fmt.Errorf("building a client for model %q on node %q: %w", modelName, node.ID, err)
+ }
// Load the model on the remote node
if loadOpts != nil {
@@ -480,7 +509,7 @@ func (r *SmartRouter) cleanupStaleLoad(ctx context.Context, node *BackendNode, m
}
replica, err := r.registry.GetNodeModel(context.WithoutCancel(ctx), node.ID, modelName, replicaIndex)
if err != nil {
- replica = &NodeModel{NodeID: node.ID, ModelName: modelName, ReplicaIndex: replicaIndex, Address: address, State: "unloading", ConfigRevision: revision, EffectiveOptionsHash: hash}
+ replica = &NodeModel{NodeID: node.ID, ModelName: modelName, ReplicaIndex: replicaIndex, WorkerLocalAddress: address, State: "unloading", ConfigRevision: revision, EffectiveOptionsHash: hash}
}
r.modelCleanup.Cleanup(context.WithoutCancel(ctx), []NodeModel{*replica}, false)
}
@@ -589,9 +618,13 @@ func (r *SmartRouter) ScheduleAndLoadModel(ctx context.Context, modelName string
// RouteResult contains the routing decision.
type RouteResult struct {
- Node *BackendNode
- Client grpc.Backend
- Release func() // Must be called when the request is done (decrements in-flight)
+ Node *BackendNode
+ Client grpc.Backend
+ // WorkerLocalAddress is where the routed replica's backend process listens
+ // on its worker. Carried so callers that record or log where a model went
+ // name the process rather than the node, which has no address.
+ WorkerLocalAddress string
+ Release func() // Must be called when the request is done (decrements in-flight)
}
// Route finds the best node for the given model and backend type.
@@ -714,14 +747,56 @@ func (r *SmartRouter) tryWarmPath(ctx context.Context, att *routeAttempt) *Route
if err != nil || node == nil {
return nil
}
- modelAddr := node.Address
- if nm.Address != "" {
- modelAddr = nm.Address
- }
+ modelAddr := nm.WorkerLocalAddress
replicaIdx := nm.ReplicaIndex
+ // A replica row that does not name its backend process cannot be routed to.
+ // There is no node address left to stand in for it, and an empty target
+ // names no process, so the request would open a stream the worker refuses
+ // as an invalid request rather than one that reaches a backend. Fall
+ // through to a cold load, which either replaces the row or reports a real
+ // failure.
+ //
+ // The row is left in place, unlike the !alive branch below which removes
+ // it. That branch has OBSERVED a backend dead; this one has observed only
+ // that the row is unreadable, which says nothing about whether a process is
+ // running on that worker. The row is also the last record that one might
+ // be: the acknowledged stop path matches on ExpectedAddress and a worker
+ // refuses a stop whose address does not match, so an empty one cannot be
+ // cleaned up through it either. Keeping the row costs a lock and a
+ // decrement per request before the cold load and leaves something an
+ // operator can see; removing it would free the replica slot for a second
+ // copy of the model while the first one, if it exists, keeps its VRAM with
+ // nothing left pointing at it.
+ //
+ // Defensive rather than reachable: installBackendOnNode below refuses an
+ // install that names no address, so no row written by this release can look
+ // like this.
+ if modelAddr == "" {
+ if err := r.registry.DecrementInFlight(ctx, node.ID, att.trackingKey, replicaIdx); err != nil {
+ xlog.Warn("Failed to release a reservation for an unnamed replica",
+ "node", node.ID, "model", att.trackingKey, "replica", replicaIdx, "error", err)
+ }
+ xlog.Warn("Loaded replica row names no backend process; cold-loading instead",
+ "node", node.ID, "model", att.trackingKey, "replica", replicaIdx)
+ return nil
+ }
+
// Verify the backend process is still alive via gRPC health check
- if !r.probeHealth(ctx, node, modelAddr) {
+ alive, probed := r.probeHealth(ctx, node, modelAddr)
+ if !probed {
+ // Nothing was asked, so nothing was learned. The row is left exactly
+ // as it was: removing it would reclaim a model that is loaded and
+ // healthy on a worker this frontend merely cannot reach right now. The
+ // reservation is released, and the cold path below reports the wiring
+ // fault with the detail a caller needs.
+ if err := r.registry.DecrementInFlight(ctx, node.ID, att.trackingKey, replicaIdx); err != nil {
+ xlog.Warn("Failed to release a reservation for an unreachable worker",
+ "node", node.ID, "model", att.trackingKey, "replica", replicaIdx, "error", err)
+ }
+ return nil
+ }
+ if !alive {
// Stale — roll back the increment, remove the specific replica row, fall through
if err := r.registry.DecrementInFlight(ctx, node.ID, att.trackingKey, replicaIdx); err != nil {
xlog.Warn("Failed to release stale routing reservation",
@@ -753,9 +828,22 @@ func (r *SmartRouter) tryWarmPath(ctx context.Context, att *routeAttempt) *Route
// call finishes, so in-flight returns to 0 when idle.
r.registry.TouchNodeModel(ctx, node.ID, att.trackingKey, replicaIdx)
r.observePrefix(att.trackingKey, att.observeChain, prefixcache.ReplicaKey{NodeID: node.ID, Replica: replicaIdx})
- grpcClient := r.buildClientForAddr(node, modelAddr, att.parallel)
+ grpcClient, err := r.buildClientForAddr(node, modelAddr, att.parallel)
+ if err != nil {
+ // The probe above builds a client for the same node and would have
+ // reported !probed, so reaching here means the dialer stopped being
+ // able to serve this node between the two. Handled the same way and for
+ // the same reason: release the reservation, leave the row alone.
+ if relErr := r.registry.DecrementInFlight(ctx, node.ID, att.trackingKey, replicaIdx); relErr != nil {
+ xlog.Warn("Failed to release a reservation for an unreachable worker",
+ "node", node.ID, "model", att.trackingKey, "replica", replicaIdx, "error", relErr)
+ }
+ xlog.Error("Cannot build a client for a loaded model: no way to reach the worker",
+ "node", node.ID, "model", att.trackingKey, "replica", replicaIdx, "error", err)
+ return nil
+ }
tracked := NewInFlightTrackingClient(grpcClient, r.registry, node.ID, att.trackingKey, replicaIdx)
- return r.newRouteResult(node, att.trackingKey, replicaIdx, grpcClient, tracked)
+ return r.newRouteResult(node, modelAddr, att.trackingKey, replicaIdx, grpcClient, tracked)
}
// coldLoad schedules the model onto a node and loads it, returning a route to
@@ -772,7 +860,7 @@ func (r *SmartRouter) coldLoad(ctx context.Context, att *routeAttempt, initialIn
r.observePrefix(att.trackingKey, att.observeChain, prefixcache.ReplicaKey{NodeID: result.Node.ID, Replica: result.ReplicaIndex})
tracked := NewInFlightTrackingClient(result.Client, r.registry, result.Node.ID, att.trackingKey, result.ReplicaIndex)
- return r.newRouteResult(result.Node, att.trackingKey, result.ReplicaIndex, result.Client, tracked), nil
+ return r.newRouteResult(result.Node, result.BackendAddr, att.trackingKey, result.ReplicaIndex, result.Client, tracked), nil
}
// newColdLoadContext builds the detached, progress-extended context a cold load
@@ -1093,11 +1181,12 @@ func (r *SmartRouter) scheduleNewModel(ctx context.Context, backendType, modelID
// If freeSlotNodes is empty (everyone full), candidateNodeIDs is whatever
// it was — we'll fall through to eviction below.
- // Node choice is wrapped in a liveness check: a node's stored status comes
- // from its HTTP heartbeat, which is a different channel from the bus that
- // carries the install. A worker that has died stops answering on the bus at
- // once but stays healthy in the database until its heartbeat ages out, so
- // without this the scheduler could commit to a node it cannot reach.
+ // Node choice is wrapped in an absence check: a node's stored status comes
+ // from its HTTP heartbeat, which is a different channel from the tunnel
+ // every install and every request travels over. A worker can heartbeat with
+ // no tunnel at all, so without this the scheduler could commit to a node
+ // nothing in the deployment can reach. See nodeMayTakeWork for which of the
+ // four presence answers is allowed to exclude, and which three are not.
selectNode := func() *BackendNode {
var candidate *BackendNode
var selErr error
@@ -1325,12 +1414,28 @@ func (r *SmartRouter) installBackendOnNode(ctx context.Context, node *BackendNod
if !reply.Success {
return "", fmt.Errorf("worker replied with error: %s", reply.Error)
}
- // Return the backend's gRPC address (per-replica port from worker)
- addr := reply.Address
- if addr == "" {
- addr = node.Address // fallback to node base address
- }
- return addr, nil
+ // Where the backend process listens on that worker. There is no node
+ // address to fall back to any more, and there should not be: a worker
+ // that reports success without naming the port it started the process
+ // on has produced nothing routable, and the failure belongs to THIS
+ // install rather than to whatever later step first tries to use the
+ // address. Substituting one would push a known-bad value into a replica
+ // row and defer the error to a probe, where its cause is no longer
+ // visible.
+ //
+ // An earlier version of this comment justified it by saying the worker
+ // would refuse the resulting empty target as an invalid stream and that
+ // the refusal would read as the worker answering about its backend.
+ // Both halves are true NOW (see cluster.IsWorkerAnswer and
+ // `unroutable`), and the decision still does not rest on either: a row
+ // written with an empty address would be reaped a probe cycle later
+ // with its cause a hop away from where it was created, and the failure
+ // belongs to this install. Reaping is a recovery, not a substitute for
+ // refusing to write the bad value.
+ if reply.WorkerLocalAddress == "" {
+ return "", fmt.Errorf("worker %s reported backend %q installed but named no address for the process", node.ID, backendType)
+ }
+ return reply.WorkerLocalAddress, nil
})
select {
case <-ctx.Done():
@@ -1343,14 +1448,25 @@ func (r *SmartRouter) installBackendOnNode(ctx context.Context, node *BackendNod
}
}
-func (r *SmartRouter) buildClientForAddr(node *BackendNode, addr string, parallel bool) grpc.Backend {
- client := r.clientFactory.NewClient(addr, parallel)
+// buildClientForAddr builds the gRPC client for a backend process running on a
+// worker node.
+//
+// addr is a port INSIDE the worker, reached over the tunnel that worker holds;
+// connecting to it from here would only work for a worker that still listens on
+// a routable address. The factory offers no way to do that, and an error is
+// returned rather than a direct-dialling client for the reason
+// ErrNoWorkerDialer gives.
+func (r *SmartRouter) buildClientForAddr(node *BackendNode, addr string, parallel bool) (grpc.Backend, error) {
+ client, err := r.clientFactory.NewClientForNode(node.ID, addr, parallel)
+ if err != nil {
+ return nil, err
+ }
// Wrap with file staging if configured
if r.fileStager != nil {
- return NewFileStagingClient(client, r.fileStager, node.ID)
+ return NewFileStagingClient(client, r.fileStager, node.ID), nil
}
- return client
+ return client, nil
}
// stageModelFiles uploads model files to the backend node via the FileStager.
@@ -1885,6 +2001,13 @@ func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir
// via a gRPC health check with a 2-second timeout. The client is closed after
// the check.
//
+// TWO results, not one. alive is what the backend said; probed is whether it
+// was asked at all. They are separate because the caller REAPS on a dead probe,
+// and a frontend that cannot reach a worker has observed nothing about that
+// worker's backends: folding the two would delete every replica row in the
+// deployment the moment the tunnel wiring was wrong, while the models carried
+// on running.
+//
// The result is memoized in r.probeCache for probeCacheTTL. With per-request
// routing every inference call lands here, and unbounded re-probing can stall
// behind a busy backend that serializes HealthCheck against active Predict.
@@ -1892,16 +2015,49 @@ func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir
// burst of N requests for a cold cache costs at most one round-trip, not N.
// Failed probes invalidate the cache so the staleness recovery path
// (DecrementInFlight + RemoveNodeModel) still triggers on the next request.
-func (r *SmartRouter) probeHealth(ctx context.Context, node *BackendNode, addr string) bool {
+//
+// The client is built OUTSIDE the memoized closure, which is what keeps an
+// unreachable worker out of the cache entirely: DoOrCachedResult only ever sees
+// a real answer. Building it costs a struct and no I/O, since the gRPC client
+// dials lazily on its first call.
+//
+// The client is the RAW factory client rather than buildClientForAddr's, on
+// purpose, and the reason is narrower than it used to be. The staging wrapper
+// no longer hides the transport: since it became a grpc.WrappedBackend it
+// carries LastDialError through, so wrapping would not cost this function the
+// answer it needs. What it buys is nothing at all, because a health check
+// stages no files, and an unused wrapper on the hottest path in the router is
+// an allocation and an indirection per probe. The earlier justification
+// ("the wrapper does not carry LastDialError through") is no longer true and is
+// recorded here so nobody re-derives the decision from it.
+func (r *SmartRouter) probeHealth(ctx context.Context, node *BackendNode, addr string) (alive, probed bool) {
+ client, err := r.clientFactory.NewClientForNode(node.ID, addr, false)
+ if err != nil {
+ xlog.Error("Cannot probe a model backend: no way to reach the worker",
+ "node", node.ID, "address", addr, "error", err)
+ return false, false
+ }
+ defer closeClient(client)
+
key := node.ID + "|" + addr
- return r.probeCache.DoOrCached(key, func() bool {
- client := r.buildClientForAddr(node, addr, false)
- defer closeClient(client)
+ alive, unreached := r.probeCache.DoOrCachedResult(key, func() (bool, error) {
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
ok, _ := client.HealthCheck(checkCtx)
- return ok
+ if ok {
+ return true, nil
+ }
+ // The RPC failed. gRPC reports a dead backend and an unreachable
+ // worker with the same code, so the only way to tell them apart is to
+ // ask the transport whether it was the one that failed.
+ return false, unroutable(client)
})
+ if unreached != nil {
+ xlog.Warn("Could not probe a model backend: no route to the worker",
+ "node", node.ID, "address", addr, "error", unreached)
+ return false, false
+ }
+ return alive, true
}
// closeClient closes a gRPC backend client if it implements io.Closer.
@@ -1915,7 +2071,7 @@ func (r *SmartRouter) probeHealth(ctx context.Context, node *BackendNode, addr s
// disconnect, handler error, validation failure after load) previously left
// in_flight pinned at 1 forever, and every eviction query requires
// in_flight = 0, so that replica's VRAM could never be reclaimed.
-func (r *SmartRouter) newRouteResult(node *BackendNode, trackingKey string, replicaIdx int, raw grpc.Backend, tracked *InFlightTrackingClient) *RouteResult {
+func (r *SmartRouter) newRouteResult(node *BackendNode, workerLocalAddr, trackingKey string, replicaIdx int, raw grpc.Backend, tracked *InFlightTrackingClient) *RouteResult {
var once sync.Once
release := func() {
once.Do(func() {
@@ -1929,8 +2085,9 @@ func (r *SmartRouter) newRouteResult(node *BackendNode, trackingKey string, repl
}
tracked.OnFirstComplete(release)
return &RouteResult{
- Node: node,
- Client: tracked,
+ Node: node,
+ Client: tracked,
+ WorkerLocalAddress: workerLocalAddr,
Release: func() {
release()
closeClient(raw)
@@ -2047,20 +2204,58 @@ func (r *SmartRouter) evictLRUAndFreeNodeFrom(ctx context.Context, candidateNode
})
if err == nil {
+ node, nodeErr := r.registry.Get(ctx, lru.NodeID)
+ if nodeErr != nil {
+ return nil, fmt.Errorf("node %s not found after eviction: %w", lru.NodeID, nodeErr)
+ }
+
+ // The third place work is committed to a node, and it has to read
+ // absence for the same reason the other two do.
+ //
+ // The query above chose on stored status, which comes from the
+ // heartbeat. A worker that heartbeats with a departed tunnel is
+ // demoted by the health monitor, but only on its next cycle, so
+ // between the departure ageing past the grace and that cycle this
+ // query still offers the node. pickReachableNode does not cover it:
+ // it only sees what the VRAM and idle selectors offer, and a node
+ // full enough to be an eviction target is exactly the node those
+ // selectors skip.
+ //
+ // Returning such a node hands the caller an install that cannot
+ // land. Demote and evict again instead: the demotion takes the node
+ // out of the next query, which selects on status, so the loop makes
+ // progress rather than re-picking it.
+ //
+ // The row this attempt deleted stays deleted, and presence is read
+ // after the transaction rather than inside it deliberately. Reading
+ // it inside would hold a FOR UPDATE lock across a query that needs a
+ // second pooled connection, which is how concurrent evictions
+ // deadlock a connection pool. The deleted row costs nothing: it
+ // named a backend on a worker no replica can reach, so nothing was
+ // serving from it to lose.
+ if !r.nodeMayTakeWork(ctx, node) {
+ xlog.Warn("Eviction target has no tunnel and its departure outlived the reconnect grace, marking unhealthy and evicting again",
+ "node", node.Name, "nodeID", node.ID, "model", lru.ModelName, "grace", r.reconnectGrace)
+ if markErr := r.registry.MarkUnhealthy(ctx, node.ID); markErr != nil {
+ // Without the demotion the next query hands back the same
+ // node, so stop rather than spin.
+ xlog.Warn("Failed to mark departed eviction target unhealthy",
+ "node", node.Name, "nodeID", node.ID, "error", markErr)
+ return nil, ErrEvictionBusy
+ }
+ continue
+ }
+
xlog.Info("Evicted LRU model to free capacity",
"node", lru.NodeID, "model", lru.ModelName, "lastUsed", lru.LastUsed)
- // Unload outside the transaction (NATS call)
+ // Unload outside the transaction.
if r.unloader != nil {
if uerr := r.unloader.UnloadModelOnNode(lru.NodeID, lru.ModelName); uerr != nil {
xlog.Warn("eviction unload failed (model already removed from registry)", "error", uerr)
}
}
- node, nodeErr := r.registry.Get(ctx, lru.NodeID)
- if nodeErr != nil {
- return nil, fmt.Errorf("node %s not found after eviction: %w", lru.NodeID, nodeErr)
- }
return node, nil
}
diff --git a/core/services/nodes/router_eviction_alias_test.go b/core/services/nodes/router_eviction_alias_test.go
index 09a6577520f3..ebd463b97a86 100644
--- a/core/services/nodes/router_eviction_alias_test.go
+++ b/core/services/nodes/router_eviction_alias_test.go
@@ -52,7 +52,7 @@ var _ = Describe("Eviction against an alias-keyed replica floor", func() {
rowID++
Expect(db.Create(&NodeModel{
ID: fmt.Sprintf("alias-row-%d", rowID), NodeID: node.ID, ModelName: model,
- Address: node.Address, State: "loaded", InFlight: 0,
+ WorkerLocalAddress: node.Address, State: "loaded", InFlight: 0,
LastUsed: time.Now().Add(-idleFor), UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
diff --git a/core/services/nodes/router_eviction_selector_test.go b/core/services/nodes/router_eviction_selector_test.go
index 8d0caaffbef5..1c283647daef 100644
--- a/core/services/nodes/router_eviction_selector_test.go
+++ b/core/services/nodes/router_eviction_selector_test.go
@@ -52,7 +52,7 @@ var _ = Describe("Eviction under a node selector", func() {
rowID++
Expect(db.Create(&NodeModel{
ID: fmt.Sprintf("row-%d", rowID), NodeID: node.ID, ModelName: model,
- Address: node.Address, State: "loaded", InFlight: inFlight,
+ WorkerLocalAddress: node.Address, State: "loaded", InFlight: inFlight,
LastUsed: time.Now().Add(-idleFor), UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
diff --git a/core/services/nodes/router_liveness.go b/core/services/nodes/router_liveness.go
index 88646162fde2..6065cb41fa92 100644
--- a/core/services/nodes/router_liveness.go
+++ b/core/services/nodes/router_liveness.go
@@ -2,56 +2,103 @@ package nodes
import (
"context"
- "errors"
+ "time"
+ "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/xlog"
- "github.com/nats-io/nats.go"
)
-// maxNodeLivenessRetries bounds how many unreachable nodes a single scheduling
+// maxNodeLivenessRetries bounds how many excluded nodes a single scheduling
// attempt discards before giving up. Each discarded node is marked unhealthy,
// so the bound only has to cover one burst of dead workers rather than the
// whole fleet.
const maxNodeLivenessRetries = 3
-// nodeAnswersOnBus reports whether a node still has a live subscription.
+// NodePresenceReader answers what this deployment can say about one worker's
+// tunnel, given the window a departure has to outlive before it counts.
//
-// Only nats.ErrNoResponders means "absent". Any other outcome, a timeout or a
-// transport hiccup, leaves the node eligible: wrongly excluding a node that is
-// merely slow costs real capacity, while the install that follows already
-// reports its own failure. When no command sender is configured there is no bus
-// to consult and every node is treated as reachable, which preserves the
-// behaviour of deployments that do not run one.
-func (r *SmartRouter) nodeAnswersOnBus(node *BackendNode) bool {
- if r.unloader == nil || node == nil {
+// A narrow port rather than the whole registry, because absence is the only
+// thing the scheduler has any business reading from it, and a wider dependency
+// here would be one every scheduling spec had to build.
+// (*cluster.Registry).Presence has exactly this shape.
+type NodePresenceReader interface {
+ Presence(ctx context.Context, nodeID string, grace time.Duration) (cluster.Presence, error)
+}
+
+// nodeMayTakeWork reports whether the scheduler may place work on a node.
+//
+// Only cluster.PresenceGone excludes, and it is a fact every replica reads
+// identically from the database rather than one this replica inferred from a
+// timeout. That is the whole change: absence used to be nats.ErrNoResponders,
+// which is one frontend's observation that nobody answered it within a budget,
+// and two replicas asking at the same moment could disagree.
+//
+// The other three values are not absence and none of them may exclude.
+// PresenceReconnecting is a worker re-homing its tunnel between replicas, or
+// one whose owning replica just died holding it: excluding it costs capacity,
+// and the demotion that follows is what turns a rolling frontend restart into a
+// fleet-wide eviction. PresenceUnknown is a worker that has never dialled or
+// whose departure aged out of retention, and the registry cannot say which, so
+// the scheduler places work and the install that follows reports its own
+// failure. A query that FAILS is not an answer at all: excluding on an
+// infrastructure failure would evict capacity for a reason that has nothing to
+// do with the worker, and a database hiccup would take out the fleet.
+//
+// It is deliberately NOT named for a route. A route to a worker is
+// ErrWorkerUnroutable, a different condition that nobody may act on; this reads
+// the one fact in the deployment that can say a worker is gone.
+//
+// When no presence reader is configured there is nothing to consult and every
+// node may take work, which preserves the behaviour of deployments that run no
+// cluster registry.
+func (r *SmartRouter) nodeMayTakeWork(ctx context.Context, node *BackendNode) bool {
+ if r.presence == nil || node == nil {
+ return true
+ }
+ p, err := r.presence.Presence(ctx, node.ID, r.reconnectGrace)
+ if err != nil {
+ xlog.Warn("Could not read node presence; scheduling as if the node were present",
+ "node", node.Name, "nodeID", node.ID, "error", err)
return true
}
- err := r.unloader.PingNode(node.ID)
- return !errors.Is(err, nats.ErrNoResponders)
+ return p != cluster.PresenceGone
}
-// pickReachableNode calls selectNode until it yields a node that still answers
-// on the bus, and returns nil when it cannot find one.
+// ReadsAbsence reports whether this scheduler has a source for absence.
+//
+// It exists to be asserted at wiring time (see core/application), and that is
+// worth stating because it is the only symptom the wiring has. A scheduler
+// built without a presence reader does not fail, log, or behave oddly: it
+// places work on workers that are gone and never demotes one, which is exactly
+// what a healthy fleet looks like. The field it reads is one line in a
+// twenty-field options literal, and losing that line silently returns the
+// deployment to the state this whole change removed.
+func (r *SmartRouter) ReadsAbsence() bool { return r != nil && r.presence != nil }
+
+// pickReachableNode calls selectNode until it yields a node nodeMayTakeWork
+// does not exclude, and returns nil when it cannot find one.
//
-// A node that does not answer is marked unhealthy before the next attempt. That
-// both removes it from the next selection, which queries only healthy nodes,
-// and tells every other scheduler in the cluster what this one just learned, so
-// the discovery is not repeated one failed request at a time.
+// An excluded node is marked unhealthy before the next attempt. That both
+// removes it from the next selection, which queries only healthy nodes, and
+// tells every other scheduler in the cluster what this one just learned, so the
+// discovery is not repeated one failed request at a time. The demotion is
+// status-only (see NodeRegistry.MarkUnhealthy): it stops placement, it does not
+// delete a row, so it stays inside what a routing fact licenses.
func (r *SmartRouter) pickReachableNode(ctx context.Context, selectNode func() *BackendNode) *BackendNode {
for range maxNodeLivenessRetries {
node := selectNode()
if node == nil {
return nil
}
- if r.nodeAnswersOnBus(node) {
+ if r.nodeMayTakeWork(ctx, node) {
return node
}
- xlog.Warn("Scheduled node is not answering on the bus, marking unhealthy and re-scheduling",
- "node", node.Name, "nodeID", node.ID)
+ xlog.Warn("Scheduled node has no tunnel and its departure outlived the reconnect grace, marking unhealthy and re-scheduling",
+ "node", node.Name, "nodeID", node.ID, "grace", r.reconnectGrace)
if err := r.registry.MarkUnhealthy(ctx, node.ID); err != nil {
// Without the demotion the next selection would hand back the same
// node, so stop rather than spin.
- xlog.Warn("Failed to mark unreachable node unhealthy",
+ xlog.Warn("Failed to mark departed node unhealthy",
"node", node.Name, "nodeID", node.ID, "error", err)
return nil
}
diff --git a/core/services/nodes/router_liveness_test.go b/core/services/nodes/router_liveness_test.go
new file mode 100644
index 000000000000..a7ced6d30e3c
--- /dev/null
+++ b/core/services/nodes/router_liveness_test.go
@@ -0,0 +1,438 @@
+package nodes
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "runtime"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/testutil"
+)
+
+// stubPresence answers the scheduler's presence question without a database.
+//
+// It exists to NAME each of the four values, so a widening of the exclusion is
+// attributed to the value that caused it. It cannot see a carrier change, which
+// is why the real-registry specs at the bottom of this file exist beside it:
+// the previous version of this suite drove a test-owned map of "dead nodes" and
+// stayed green for the whole window in which every healthy worker read as
+// absent.
+type stubPresence struct {
+ answer cluster.Presence
+ answerFor map[string]cluster.Presence
+ err error
+
+ nodes []string
+ graces []time.Duration
+}
+
+func (s *stubPresence) Presence(_ context.Context, nodeID string, grace time.Duration) (cluster.Presence, error) {
+ s.nodes = append(s.nodes, nodeID)
+ s.graces = append(s.graces, grace)
+ if s.err != nil {
+ // PresenceUnknown alongside the error, the way the real registry
+ // answers: a value nobody may act on rather than one that reads as a
+ // verdict.
+ return cluster.PresenceUnknown, s.err
+ }
+ if p, ok := s.answerFor[nodeID]; ok {
+ return p, nil
+ }
+ return s.answer, nil
+}
+
+// selectorReturning hands back each node in turn, mimicking a scheduler that
+// re-picks after the previous choice was demoted.
+func selectorReturning(nodes ...*BackendNode) func() *BackendNode {
+ i := 0
+ return func() *BackendNode {
+ if i >= len(nodes) {
+ return nil
+ }
+ n := nodes[i]
+ i++
+ return n
+ }
+}
+
+var _ = Describe("Scheduling and node presence", func() {
+ var (
+ reg *fakeModelRouter
+ presence *stubPresence
+ router *SmartRouter
+ ctx context.Context
+ )
+
+ // The grace every spec measures against. It is the operator's trade, so it
+ // is also what the scheduler must hand the registry: a scheduler that asked
+ // with a constant of its own would ignore --worker-reconnect-grace entirely
+ // while every spec below that only checks a Presence VALUE stayed green.
+ const grace = 60 * time.Second
+
+ newNode := func(id string) *BackendNode {
+ return &BackendNode{ID: id, Name: id, Address: id + ":50051"}
+ }
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ reg = &fakeModelRouter{}
+ presence = &stubPresence{answer: cluster.PresenceConnected}
+ router = NewSmartRouter(reg, SmartRouterOptions{Presence: presence, ReconnectGrace: grace})
+ })
+
+ DescribeTable("decides whether a node may be given work",
+ func(p cluster.Presence, eligible bool) {
+ presence.answer = p
+ Expect(router.nodeMayTakeWork(ctx, newNode("node-1"))).To(Equal(eligible))
+ },
+ Entry("connected: eligible", cluster.PresenceConnected, true),
+ // The catastrophe case. A worker re-homing between replicas, or a
+ // replica that just died holding it, must NOT be excluded: excluding it
+ // costs capacity, and marking it unhealthy is what turns a rolling
+ // frontend restart into a fleet-wide eviction.
+ Entry("reconnecting: still eligible", cluster.PresenceReconnecting, true),
+ // A node with no connection row has never dialled or its departure aged
+ // out. Presence cannot say which, so scheduling does not exclude it and
+ // the install that follows reports its own failure.
+ Entry("unknown: still eligible", cluster.PresenceUnknown, true),
+ Entry("gone: not eligible", cluster.PresenceGone, false),
+ )
+
+ It("asks with the operator's configured grace rather than a constant of its own", func() {
+ router.nodeMayTakeWork(ctx, newNode("node-1"))
+
+ Expect(presence.graces).To(Equal([]time.Duration{grace}))
+ })
+
+ It("falls back to the documented grace when a presence reader is wired without one", func() {
+ // A zero window would make every departure a verdict the instant it was
+ // stamped, so every worker that lost a tunnel a moment ago would be
+ // gone. Nothing else in this file constructs a router with a presence
+ // reader and no grace, which is exactly how a caller reaches this.
+ defaulted := NewSmartRouter(reg, SmartRouterOptions{Presence: presence})
+
+ defaulted.nodeMayTakeWork(ctx, newNode("node-1"))
+
+ Expect(presence.graces).To(Equal([]time.Duration{config.DefaultWorkerReconnectGrace}))
+ })
+
+ It("does not mark a reconnecting node unhealthy", func() {
+ node := newNode("re-homing")
+ presence.answer = cluster.PresenceReconnecting
+
+ Expect(router.pickReachableNode(ctx, selectorReturning(node))).To(Equal(node))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+
+ It("marks a gone node unhealthy exactly once and reschedules", func() {
+ gone, live := newNode("gone-node"), newNode("live-node")
+ presence.answerFor = map[string]cluster.Presence{
+ gone.ID: cluster.PresenceGone,
+ live.ID: cluster.PresenceConnected,
+ }
+
+ picked := router.pickReachableNode(ctx, selectorReturning(gone, live))
+
+ Expect(picked).To(Equal(live))
+ Expect(reg.markedUnhealthy).To(Equal([]string{gone.ID}))
+ Expect(presence.nodes).To(Equal([]string{gone.ID, live.ID}))
+ })
+
+ It("treats a presence query FAILURE as eligible, never as absence", func() {
+ // A database hiccup must not evict the fleet. The error carries
+ // PresenceUnknown, so a read that acted on the value while ignoring the
+ // error would still be eligible here; what separates the two is that
+ // nothing may be concluded, which the demotion assertion covers.
+ node := newNode("only-node")
+ presence.err = errors.New("connection reset")
+
+ Expect(router.nodeMayTakeWork(ctx, node)).To(BeTrue())
+ Expect(router.pickReachableNode(ctx, selectorReturning(node))).To(Equal(node))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+
+ It("treats every node as eligible when no presence reader is configured", func() {
+ // A single-node deployment has no cluster registry to ask, and nothing
+ // there has a tunnel to lose.
+ plain := NewSmartRouter(reg, SmartRouterOptions{})
+ node := newNode("only-node")
+
+ Expect(plain.pickReachableNode(ctx, selectorReturning(node))).To(Equal(node))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+
+ It("takes the first eligible node without asking about the rest", func() {
+ first, second := newNode("first"), newNode("second")
+
+ Expect(router.pickReachableNode(ctx, selectorReturning(first, second))).To(Equal(first))
+ Expect(presence.nodes).To(Equal([]string{"first"}))
+ })
+
+ It("gives up rather than spinning when every node is gone", func() {
+ presence.answer = cluster.PresenceGone
+ a, b, c, d := newNode("a"), newNode("b"), newNode("c"), newNode("d")
+
+ picked := router.pickReachableNode(ctx, selectorReturning(a, b, c, d))
+
+ Expect(picked).To(BeNil())
+ Expect(len(presence.nodes)).To(BeNumerically("<=", maxNodeLivenessRetries))
+ })
+
+ It("stops when the demotion itself fails, so it cannot loop on one node", func() {
+ presence.answer = cluster.PresenceGone
+ dead := newNode("dead-node")
+ reg.markUnhealthyErr = errors.New("database is down")
+
+ picked := router.pickReachableNode(ctx, selectorReturning(dead, dead, dead))
+
+ Expect(picked).To(BeNil())
+ Expect(presence.nodes).To(Equal([]string{"dead-node"}))
+ })
+})
+
+// The same decision, against the registry that answers it in production, on the
+// database clock.
+//
+// The stub above can only prove that the scheduler acts correctly on a value it
+// was handed. These prove that the value it is handed is the one the deployment
+// holds: that Presence is actually consulted, that the operator's grace reaches
+// it, and that a departure inside the grace and a departure past it are told
+// apart by the DATABASE rather than by this process.
+var _ = Describe("Scheduling against the cluster registry that answers presence", func() {
+ var (
+ ctx context.Context
+ db *gorm.DB
+ clusterR *cluster.Registry
+ reg *fakeModelRouter
+ router *SmartRouter
+ )
+
+ const (
+ grace = 60 * time.Second
+ instance = "inst-a"
+ )
+
+ // ageDeparture pushes a worker's departure into the past ON THE DATABASE
+ // CLOCK. A Go-side time.Now().Add(-d) would age the row against this
+ // process's clock and then compare it against the database's, which is the
+ // skew the whole window is written to be immune to.
+ ageDeparture := func(nodeID string, by time.Duration) {
+ GinkgoHelper()
+ res := db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ by.Seconds(), nodeID)
+ Expect(res.Error).ToNot(HaveOccurred())
+ // An UPDATE matching nothing succeeds. Without this, the
+ // inside-the-grace spec below would return the same verdict whether its
+ // scripted input landed or not, because a departure stamped a moment
+ // ago is also inside the grace.
+ Expect(res.RowsAffected).To(Equal(int64(1)),
+ "precondition: the departure this spec ages must exist")
+ }
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ clusterR = cluster.NewRegistry(db)
+ Expect(clusterR.Register(ctx, instance, "10.0.0.1:8080", "v1")).To(Succeed())
+ reg = &fakeModelRouter{}
+ router = NewSmartRouter(reg, SmartRouterOptions{Presence: clusterR, ReconnectGrace: grace})
+ })
+
+ node := func(id string) *BackendNode {
+ return &BackendNode{ID: id, Name: id, Address: id + ":50051"}
+ }
+
+ It("schedules onto a worker whose tunnel a live replica holds", func() {
+ _, err := clusterR.Claim(ctx, "worker-connected", instance)
+ Expect(err).ToNot(HaveOccurred())
+
+ n := node("worker-connected")
+ Expect(router.pickReachableNode(ctx, selectorReturning(n))).To(Equal(n))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+
+ It("schedules onto a worker whose tunnel was lost inside the grace, without demoting it", func() {
+ // The rolling-restart case in its real form: the row records a
+ // departure, and the worker is at this moment re-dialling the load
+ // balancer.
+ epoch, err := clusterR.Claim(ctx, "worker-rehoming", instance)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(clusterR.Release(ctx, "worker-rehoming", instance, epoch)).To(Succeed())
+ ageDeparture("worker-rehoming", grace/2)
+
+ n := node("worker-rehoming")
+ Expect(router.pickReachableNode(ctx, selectorReturning(n))).To(Equal(n))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+
+ It("excludes and demotes a worker whose departure has outlived the grace", func() {
+ epoch, err := clusterR.Claim(ctx, "worker-gone", instance)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(clusterR.Release(ctx, "worker-gone", instance, epoch)).To(Succeed())
+ // Just past the edge, not an order of magnitude past it: a window only
+ // ever aged ten times its own width is a window nothing pins.
+ ageDeparture("worker-gone", grace+5*time.Second)
+
+ _, err = clusterR.Claim(ctx, "worker-live", instance)
+ Expect(err).ToNot(HaveOccurred())
+ gone, live := node("worker-gone"), node("worker-live")
+
+ Expect(router.pickReachableNode(ctx, selectorReturning(gone, live))).To(Equal(live))
+ Expect(reg.markedUnhealthy).To(Equal([]string{"worker-gone"}))
+ })
+
+ It("schedules onto a worker that has never dialled a tunnel", func() {
+ // No connection row at all. The registry cannot tell a worker still
+ // starting up from one whose departure aged out of retention, so it
+ // answers unknown and the scheduler places work; the install that
+ // follows reports its own failure if the worker really is not there.
+ n := node("worker-never-seen")
+ Expect(router.pickReachableNode(ctx, selectorReturning(n))).To(Equal(n))
+ Expect(reg.markedUnhealthy).To(BeEmpty())
+ })
+})
+
+// The third place work is committed to a node.
+//
+// pickReachableNode covers what the VRAM and idle selectors offer, and the
+// health monitor demotes a departed worker on its next cycle. Neither covers
+// eviction: a node full enough to be an eviction target is exactly the node
+// those selectors skip, and between a departure ageing past the grace and the
+// monitor's next cycle the eviction query still offers it, because that query
+// selects on the stored status and the status comes from the heartbeat.
+//
+// Handing such a node back is not a lost eviction, it is a scheduled install
+// that cannot land, on a node the caller then reports as the failure.
+var _ = Describe("Eviction and a worker whose tunnel is gone", func() {
+ var (
+ ctx context.Context
+ db *gorm.DB
+ registry *NodeRegistry
+ clusterR *cluster.Registry
+ router *SmartRouter
+ )
+
+ const (
+ grace = 60 * time.Second
+ instance = "inst-evict"
+ )
+
+ BeforeEach(func() {
+ if runtime.GOOS == "darwin" {
+ Skip("testcontainers requires Docker, not available on macOS CI")
+ }
+ ctx = context.Background()
+ db = testutil.SetupTestDB()
+ var err error
+ registry, err = NewNodeRegistry(db)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.Migrate(ctx, db)).To(Succeed())
+ clusterR = cluster.NewRegistry(db)
+ Expect(clusterR.Register(ctx, instance, "10.0.0.1:8080", "v1")).To(Succeed())
+ router = NewSmartRouter(registry, SmartRouterOptions{DB: db, Presence: clusterR, ReconnectGrace: grace})
+ })
+
+ register := func(name string) *BackendNode {
+ GinkgoHelper()
+ node := &BackendNode{Name: name, NodeType: NodeTypeBackend, Address: name + ":50051"}
+ Expect(registry.Register(ctx, node, true)).To(Succeed())
+ fetched, err := registry.GetByName(ctx, name)
+ Expect(err).ToNot(HaveOccurred())
+ return fetched
+ }
+
+ // holdTunnel leaves the worker's tunnel claimed by the live replica.
+ holdTunnel := func(nodeID string) {
+ GinkgoHelper()
+ _, err := clusterR.Claim(ctx, nodeID, instance)
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // departTunnel releases the worker's tunnel and ages the departure ON THE
+ // DATABASE CLOCK, which is where the window is measured.
+ departTunnel := func(nodeID string, by time.Duration) {
+ GinkgoHelper()
+ epoch, err := clusterR.Claim(ctx, nodeID, instance)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(clusterR.Release(ctx, nodeID, instance, epoch)).To(Succeed())
+ res := db.WithContext(ctx).Exec(
+ `UPDATE node_connections SET disconnected_at = now() - make_interval(secs => ?) WHERE node_id = ?`,
+ by.Seconds(), nodeID)
+ Expect(res.Error).ToNot(HaveOccurred())
+ Expect(res.RowsAffected).To(Equal(int64(1)),
+ "precondition: the departure this spec ages must exist, or the spec proves nothing")
+ }
+
+ seeded := 0
+ seedLoaded := func(node *BackendNode, model string, idleFor time.Duration) {
+ GinkgoHelper()
+ seeded++
+ Expect(db.Create(&NodeModel{
+ ID: fmt.Sprintf("evict-row-%d", seeded), NodeID: node.ID, ModelName: model,
+ WorkerLocalAddress: node.Address, State: "loaded", InFlight: 0,
+ LastUsed: time.Now().Add(-idleFor), UpdatedAt: time.Now(),
+ }).Error).To(Succeed())
+ }
+
+ statusOf := func(id string) string {
+ GinkgoHelper()
+ n, err := registry.Get(ctx, id)
+ Expect(err).ToNot(HaveOccurred())
+ return n.Status
+ }
+
+ It("does not hand back a node whose departure outlived the grace, and demotes it", func() {
+ gone, live := register("gone-node"), register("live-node")
+ departTunnel(gone.ID, grace+5*time.Second)
+ holdTunnel(live.ID)
+ // The global LRU sits on the departed node, so an eviction that read
+ // only the stored status would pick it and return it.
+ seedLoaded(gone, "lru-on-gone", 2*time.Hour)
+ seedLoaded(live, "newer-on-live", time.Hour)
+
+ node, err := router.evictLRUAndFreeNodeFrom(ctx, nil)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.ID).To(Equal(live.ID))
+ Expect(statusOf(gone.ID)).To(Equal(StatusUnhealthy),
+ "the departure has to reach every other replica, not just this eviction")
+ Expect(statusOf(live.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("still evicts from a node whose tunnel went inside the grace", func() {
+ // The negative control. A worker re-dialling the load balancer is not
+ // absent, and excluding it would make a rolling frontend restart look
+ // like a cluster with no capacity left to free.
+ rehoming := register("rehoming-node")
+ departTunnel(rehoming.ID, grace/2)
+ seedLoaded(rehoming, "lru-on-rehoming", 2*time.Hour)
+
+ node, err := router.evictLRUAndFreeNodeFrom(ctx, nil)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.ID).To(Equal(rehoming.ID))
+ Expect(statusOf(rehoming.ID)).To(Equal(StatusHealthy))
+ })
+
+ It("gives up rather than returning a departed node when every candidate has gone", func() {
+ // Without the check this returned a node with no route at all, and the
+ // caller reported the install failure instead of the absence.
+ gone := register("only-node-gone")
+ departTunnel(gone.ID, grace+5*time.Second)
+ seedLoaded(gone, "lru-on-only-gone", time.Hour)
+
+ _, err := router.evictLRUAndFreeNodeFrom(ctx, nil)
+
+ Expect(err).To(HaveOccurred())
+ Expect(statusOf(gone.ID)).To(Equal(StatusUnhealthy))
+ })
+})
diff --git a/core/services/nodes/router_load_budget_test.go b/core/services/nodes/router_load_budget_test.go
index 921b19905d4a..d004a0e3fc8d 100644
--- a/core/services/nodes/router_load_budget_test.go
+++ b/core/services/nodes/router_load_budget_test.go
@@ -86,6 +86,10 @@ type holdClientFactory struct{ client *holdBackend }
func (f *holdClientFactory) NewClient(_ string, _ bool) grpc.Backend { return f.client }
+func (f *holdClientFactory) NewClientForNode(_, address string, parallel bool) (grpc.Backend, error) {
+ return f.NewClient(address, parallel), nil
+}
+
var _ = Describe("size-derived remote LoadModel budget", func() {
// Production, on an NVIDIA Jetson Thor worker: a 70 GB video checkpoint
// (longcat-video-avatar-1.5) failed reproducibly after 953.5s with
@@ -108,7 +112,7 @@ var _ = Describe("size-derived remote LoadModel budget", func() {
backend = &holdBackend{}
factory = &holdClientFactory{client: backend}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
dir = GinkgoT().TempDir()
})
diff --git a/core/services/nodes/router_load_job_test.go b/core/services/nodes/router_load_job_test.go
index 65d1ef939c35..0454c3b2e454 100644
--- a/core/services/nodes/router_load_job_test.go
+++ b/core/services/nodes/router_load_job_test.go
@@ -51,7 +51,7 @@ var _ = Describe("Route cold-load jobs", func() {
backend = &stubBackend{healthResult: true, loadResult: &pb.Result{Success: true}}
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
diff --git a/core/services/nodes/router_load_timeout_test.go b/core/services/nodes/router_load_timeout_test.go
index 295dc35d81fd..49a014cdc86e 100644
--- a/core/services/nodes/router_load_timeout_test.go
+++ b/core/services/nodes/router_load_timeout_test.go
@@ -53,6 +53,10 @@ type deadlineClientFactory struct{ client *deadlineBackend }
func (f *deadlineClientFactory) NewClient(_ string, _ bool) grpc.Backend { return f.client }
+func (f *deadlineClientFactory) NewClientForNode(_, address string, parallel bool) (grpc.Backend, error) {
+ return f.NewClient(address, parallel), nil
+}
+
var _ = Describe("remote LoadModel deadline", func() {
var (
reg *fakeModelRouter
@@ -67,7 +71,7 @@ var _ = Describe("remote LoadModel deadline", func() {
backend = &deadlineBackend{}
factory = &deadlineClientFactory{client: backend}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
diff --git a/core/services/nodes/router_nats_liveness_test.go b/core/services/nodes/router_nats_liveness_test.go
deleted file mode 100644
index ec4820c9f0a8..000000000000
--- a/core/services/nodes/router_nats_liveness_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package nodes
-
-import (
- "context"
- "errors"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-// A node's stored status comes from its HTTP heartbeat, but work is dispatched
-// over NATS. A worker that dies stops answering on the bus immediately and
-// keeps its healthy status until the heartbeat ages out, so the scheduler could
-// commit to a node it could not reach. The request then failed outright with
-// "no responders available" rather than moving to a node that was actually up.
-var _ = Describe("Scheduling past a node that left the bus", func() {
- var (
- reg *fakeModelRouter
- fake *fakeUnloader
- router *SmartRouter
- )
-
- newNode := func(id string) *BackendNode {
- return &BackendNode{ID: id, Name: id, Address: id + ":50051"}
- }
-
- // selectorReturning hands back each node in turn, mimicking a scheduler
- // that re-picks after the previous choice was demoted.
- selectorReturning := func(nodes ...*BackendNode) func() *BackendNode {
- i := 0
- return func() *BackendNode {
- if i >= len(nodes) {
- return nil
- }
- n := nodes[i]
- i++
- return n
- }
- }
-
- BeforeEach(func() {
- reg = &fakeModelRouter{}
- fake = &fakeUnloader{deadNodes: map[string]bool{}}
- router = NewSmartRouter(reg, SmartRouterOptions{Unloader: fake})
- })
-
- It("passes over a node that no longer answers and takes one that does", func() {
- dead, alive := newNode("dead-node"), newNode("alive-node")
- fake.deadNodes["dead-node"] = true
-
- picked := router.pickReachableNode(context.Background(), selectorReturning(dead, alive))
-
- Expect(picked).ToNot(BeNil())
- Expect(picked.ID).To(Equal("alive-node"))
- Expect(fake.pingCalls).To(Equal([]string{"dead-node", "alive-node"}))
- })
-
- It("demotes the absent node so other schedulers stop choosing it", func() {
- dead, alive := newNode("dead-node"), newNode("alive-node")
- fake.deadNodes["dead-node"] = true
-
- router.pickReachableNode(context.Background(), selectorReturning(dead, alive))
-
- Expect(reg.markedUnhealthy).To(Equal([]string{"dead-node"}))
- })
-
- It("takes the first node when it answers, without probing further", func() {
- first, second := newNode("first"), newNode("second")
-
- picked := router.pickReachableNode(context.Background(), selectorReturning(first, second))
-
- Expect(picked.ID).To(Equal("first"))
- Expect(fake.pingCalls).To(Equal([]string{"first"}))
- })
-
- It("gives up rather than spinning when every node is gone", func() {
- a, b, c, d := newNode("a"), newNode("b"), newNode("c"), newNode("d")
- for _, id := range []string{"a", "b", "c", "d"} {
- fake.deadNodes[id] = true
- }
-
- picked := router.pickReachableNode(context.Background(), selectorReturning(a, b, c, d))
-
- Expect(picked).To(BeNil())
- Expect(len(fake.pingCalls)).To(BeNumerically("<=", maxNodeLivenessRetries))
- })
-
- It("stops when the demotion itself fails, so it cannot loop on one node", func() {
- dead := newNode("dead-node")
- fake.deadNodes["dead-node"] = true
- reg.markUnhealthyErr = errors.New("database is down")
-
- picked := router.pickReachableNode(context.Background(), selectorReturning(dead, dead, dead))
-
- Expect(picked).To(BeNil())
- Expect(fake.pingCalls).To(Equal([]string{"dead-node"}))
- })
-
- // Only a no-responders answer proves absence. Excluding a node that is
- // merely slow would cost real capacity.
- It("keeps a node that answers slowly or errors for another reason", func() {
- slow := newNode("slow-node")
- fake.pingErr = errors.New("timeout waiting for reply")
-
- picked := router.pickReachableNode(context.Background(), selectorReturning(slow))
-
- Expect(picked).ToNot(BeNil())
- Expect(picked.ID).To(Equal("slow-node"))
- Expect(reg.markedUnhealthy).To(BeEmpty())
- })
-
- It("treats every node as reachable when no command sender is configured", func() {
- plain := NewSmartRouter(reg, SmartRouterOptions{})
- node := newNode("only-node")
-
- Expect(plain.pickReachableNode(context.Background(), selectorReturning(node))).To(Equal(node))
- })
-})
diff --git a/core/services/nodes/router_reap_load_test.go b/core/services/nodes/router_reap_load_test.go
index 67376c06f535..10801385cd97 100644
--- a/core/services/nodes/router_reap_load_test.go
+++ b/core/services/nodes/router_reap_load_test.go
@@ -45,6 +45,10 @@ type failingClientFactory struct{ client *failingLoadBackend }
func (f *failingClientFactory) NewClient(_ string, _ bool) grpc.Backend { return f.client }
+func (f *failingClientFactory) NewClientForNode(_, address string, parallel bool) (grpc.Backend, error) {
+ return f.NewClient(address, parallel), nil
+}
+
// replicaSlotRouter pins the replica slot scheduleAndLoad allocates so a spec
// can assert the reaped process key carries the real index, not a hardcoded 0.
type replicaSlotRouter struct {
@@ -69,7 +73,7 @@ var _ = Describe("reaping an abandoned remote load", func() {
reg = &replicaSlotRouter{fakeModelRouter: base, replica: 2}
backend = &failingLoadBackend{}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
diff --git a/core/services/nodes/router_reservation_test.go b/core/services/nodes/router_reservation_test.go
index c9f6cf73bb6f..ad251fb6dd96 100644
--- a/core/services/nodes/router_reservation_test.go
+++ b/core/services/nodes/router_reservation_test.go
@@ -50,7 +50,7 @@ var _ = Describe("SmartRouter routing reservation", func() {
newResult := func() *RouteResult {
raw := &stubBackend{}
tracked := NewInFlightTrackingClient(raw, registry, node.ID, "m", 0)
- return router.newRouteResult(node, "m", 0, raw, tracked)
+ return router.newRouteResult(node, "127.0.0.1:50052", "m", 0, raw, tracked)
}
It("releases the reservation when the route is torn down without any inference", func() {
diff --git a/core/services/nodes/router_revision_lifecycle_test.go b/core/services/nodes/router_revision_lifecycle_test.go
index 7b1de6144b72..6670f76ad8c6 100644
--- a/core/services/nodes/router_revision_lifecycle_test.go
+++ b/core/services/nodes/router_revision_lifecycle_test.go
@@ -56,7 +56,7 @@ var _ = Describe("revision-bound load publication", func() {
node = &BackendNode{Name: "revision-worker", NodeType: NodeTypeBackend, Address: "10.0.0.1:50051", TotalVRAM: 64_000_000_000, AvailableVRAM: 64_000_000_000}
Expect(registry.Register(ctx, node, true)).To(Succeed())
backend = &stubBackend{healthResult: true, loadResult: &pb.Result{Success: true}}
- unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"}}
+ unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"}}
})
It("quarantines and exactly stops a load that finishes after its revision changes", func() {
diff --git a/core/services/nodes/router_staging_context_test.go b/core/services/nodes/router_staging_context_test.go
index f0b07a7a53e8..1a577df373c5 100644
--- a/core/services/nodes/router_staging_context_test.go
+++ b/core/services/nodes/router_staging_context_test.go
@@ -52,8 +52,8 @@ var _ = Describe("Route cold-load staging context", func() {
backend := &stubBackend{loadResult: &pb.Result{Success: true}}
factory := &stubClientFactory{client: backend}
unloader := &fakeUnloader{installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.1:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.1:9001",
}}
stager := &cancelOnStageStager{}
diff --git a/core/services/nodes/router_staging_deadline_test.go b/core/services/nodes/router_staging_deadline_test.go
index 35d1d2ae568c..66026987d9c2 100644
--- a/core/services/nodes/router_staging_deadline_test.go
+++ b/core/services/nodes/router_staging_deadline_test.go
@@ -97,8 +97,8 @@ var _ = Describe("cold-load staging deadline", func() {
}
factory = &stubClientFactory{client: &stubBackend{loadResult: &pb.Result{Success: true}}}
unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.1:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.1:9001",
}}
modelDir = GinkgoT().TempDir()
})
diff --git a/core/services/nodes/router_test.go b/core/services/nodes/router_test.go
index 015cacb3040b..2c9ab96d5409 100644
--- a/core/services/nodes/router_test.go
+++ b/core/services/nodes/router_test.go
@@ -17,7 +17,6 @@ import (
"github.com/mudler/LocalAI/pkg/distributedhdr"
grpc "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
- "github.com/nats-io/nats.go"
ggrpc "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
@@ -466,6 +465,10 @@ func (f *stubClientFactory) NewClient(_ string, _ bool) grpc.Backend {
return f.client
}
+func (f *stubClientFactory) NewClientForNode(_, address string, parallel bool) (grpc.Backend, error) {
+ return f.NewClient(address, parallel), nil
+}
+
// ---------------------------------------------------------------------------
// Fake NodeCommandSender (unloader)
// ---------------------------------------------------------------------------
@@ -492,13 +495,6 @@ type fakeUnloader struct {
stopErr error
unloadCalls []string
- // deadNodes names the nodes PingNode reports as absent from the bus, and
- // pingCalls records every node it was asked about, in order.
- deadNodes map[string]bool
- pingCalls []string
- // pingErr is returned for nodes not in deadNodes, so a spec can model a
- // node that is reachable but answering badly.
- pingErr error
unloadErr error
}
@@ -562,17 +558,6 @@ func (f *fakeModelRouter) MarkUnhealthy(_ context.Context, nodeID string) error
return f.markUnhealthyErr
}
-func (f *fakeUnloader) PingNode(nodeID string) error {
- f.mu.Lock()
- f.pingCalls = append(f.pingCalls, nodeID)
- dead := f.deadNodes[nodeID]
- f.mu.Unlock()
- if dead {
- return nats.ErrNoResponders
- }
- return f.pingErr
-}
-
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -595,8 +580,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.1:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -604,7 +589,7 @@ var _ = Describe("SmartRouter", func() {
Context("model already loaded on a healthy node", func() {
It("returns the client and a release function", func() {
node := &BackendNode{ID: "n1", Name: "node-1", Address: "10.0.0.1:50051"}
- nm := &NodeModel{NodeID: "n1", ModelName: "my-model", Address: "10.0.0.1:9001"}
+ nm := &NodeModel{NodeID: "n1", ModelName: "my-model", WorkerLocalAddress: "10.0.0.1:9001"}
reg.findAndLockNode = node
reg.findAndLockNM = nm
backend.healthResult = true
@@ -746,8 +731,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.1:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -908,8 +893,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.1:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -1005,15 +990,15 @@ var _ = Describe("SmartRouter", func() {
factory := &stubClientFactory{client: backend}
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{
- Success: true,
- Address: "10.0.0.71:9001",
+ Success: true,
+ WorkerLocalAddress: "10.0.0.71:9001",
},
}
reg := &fakeModelRouter{
// Step 1: cached model found on old node
findAndLockNode: cachedNode,
- findAndLockNM: &NodeModel{NodeID: "n-old", ModelName: "sel-model", Address: "10.0.0.70:9001"},
+ findAndLockNM: &NodeModel{NodeID: "n-old", ModelName: "sel-model", WorkerLocalAddress: "10.0.0.70:9001"},
// Scheduling config with selector that old node does NOT match
getModelScheduling: &ModelSchedulingConfig{
ModelName: "sel-model",
@@ -1274,7 +1259,7 @@ var _ = Describe("SmartRouter", func() {
started := make(chan struct{}, 5)
release := make(chan struct{})
unloader := &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"},
}
unloader.installHook = func() {
started <- struct{}{}
@@ -1313,7 +1298,7 @@ var _ = Describe("SmartRouter", func() {
It("does NOT coalesce installs for different (modelID, replica) keys", func() {
node := &BackendNode{ID: "n1", Name: "node-1", Address: "10.0.0.1:50051"}
unloader := &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"},
}
router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
Unloader: unloader,
@@ -1328,6 +1313,44 @@ var _ = Describe("SmartRouter", func() {
Expect(err3).ToNot(HaveOccurred())
Expect(unloader.installCalls).To(HaveLen(3))
})
+
+ It("returns the address the worker named for the backend process", func() {
+ node := &BackendNode{ID: "n1", Name: "node-1"}
+ unloader := &fakeUnloader{
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:50100"},
+ }
+ router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
+ Unloader: unloader,
+ ClientFactory: &stubClientFactory{client: &stubBackend{}},
+ })
+
+ addr, err := router.installBackendOnNode(context.Background(), node, "llama-cpp", "model-A", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(addr).To(Equal("127.0.0.1:50100"))
+ })
+
+ It("fails when the worker reports success but names no address", func() {
+ // There is no node address left to stand in for it. Substituting one
+ // used to be the behaviour here, and with workers no longer
+ // advertising it would substitute the empty string: the frontend
+ // would then open a stream naming an empty target, the worker would
+ // refuse it as invalid, and that refusal reads as the WORKER
+ // answering about its backend rather than as this install having
+ // produced nothing routable.
+ node := &BackendNode{ID: "n1", Name: "node-1"}
+ unloader := &fakeUnloader{
+ installReply: &messaging.BackendInstallReply{Success: true},
+ }
+ router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
+ Unloader: unloader,
+ ClientFactory: &stubClientFactory{client: &stubBackend{}},
+ })
+
+ addr, err := router.installBackendOnNode(context.Background(), node, "llama-cpp", "model-A", 0)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("named no address"))
+ Expect(addr).To(BeEmpty())
+ })
})
})
@@ -1387,7 +1410,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
backend = &stubBackend{healthResult: true}
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
- installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
+ installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
@@ -1395,7 +1418,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// "m" on node "X", plus matching replica stats so buildPreference can run.
loadedReg := func() *fakeModelRouter {
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
- nm := &NodeModel{NodeID: "X", ModelName: "m", Address: "10.0.0.1:9001"}
+ nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
return &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
@@ -1486,7 +1509,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// node. This is the replica-granular regression this change fixes.
idx := prefixcache.NewIndex(prefixcache.DefaultConfig())
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
- nm := &NodeModel{NodeID: "X", ModelName: "m", ReplicaIndex: 0, Address: "10.0.0.1:9001"}
+ nm := &NodeModel{NodeID: "X", ModelName: "m", ReplicaIndex: 0, WorkerLocalAddress: "10.0.0.1:9001"}
reg := &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
@@ -1565,7 +1588,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// forced-disturb signal. findAndLockNode returns Y so Route succeeds.
disturbReg := func() *fakeModelRouter {
nodeY := &BackendNode{ID: "Y", Name: "node-y", Address: "10.0.0.2:50051"}
- nm := &NodeModel{NodeID: "Y", ModelName: "m", Address: "10.0.0.2:9001"}
+ nm := &NodeModel{NodeID: "Y", ModelName: "m", WorkerLocalAddress: "10.0.0.2:9001"}
return &fakeModelRouter{
findAndLockNode: nodeY,
findAndLockNM: nm,
diff --git a/core/services/nodes/router_unnamed_replica_test.go b/core/services/nodes/router_unnamed_replica_test.go
new file mode 100644
index 000000000000..a6a01fda7526
--- /dev/null
+++ b/core/services/nodes/router_unnamed_replica_test.go
@@ -0,0 +1,97 @@
+package nodes
+
+import (
+ "context"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// A replica row carries the address of the backend process it names, and that
+// address is how the frontend says WHICH process on a worker it means. A row
+// without one names nothing, and the warm path has to decline it rather than
+// route with an empty target.
+//
+// This is defensive: installBackendOnNode now guarantees a non-empty address
+// before any row is written, so the only rows that can look like this are ones
+// an older frontend wrote. It is specced anyway because the branch touches an
+// in-flight reservation, and a reservation that is taken and not returned pins
+// the replica against every eviction query for the life of the row.
+var _ = Describe("SmartRouter warm path with an unnamed replica", func() {
+ var (
+ registry *fakeModelRouterForSmartRouter
+ clients *fakeBackendClientFactory
+ router *SmartRouter
+ node *BackendNode
+ )
+
+ BeforeEach(func() {
+ node = &BackendNode{ID: "node-1", Name: "node-1", Status: StatusHealthy}
+ registry = newFakeModelRouterForSmartRouter()
+ registry.node = node
+ clients = newFakeBackendClientFactory()
+ router = NewSmartRouter(registry, SmartRouterOptions{ClientFactory: clients})
+ })
+
+ warm := func() *RouteResult {
+ return router.tryWarmPath(context.Background(), &routeAttempt{trackingKey: "m", modelName: "m"})
+ }
+
+ Context("when the row names no backend process", func() {
+ BeforeEach(func() {
+ registry.nodeModel = &NodeModel{NodeID: node.ID, ModelName: "m", ReplicaIndex: 0}
+ })
+
+ It("declines the warm path so the caller cold-loads", func() {
+ Expect(warm()).To(BeNil())
+ })
+
+ It("never asks for a client, so the empty target reaches no dialler", func() {
+ // The failure this prevents: an empty target opens a stream the
+ // worker refuses as an invalid request. That refusal is an answer
+ // FROM the worker, so it is the one failure on the whole path that
+ // is real evidence about a backend, and this row is not entitled to
+ // produce evidence about anything.
+ Expect(warm()).To(BeNil())
+ Expect(clients.nodesSeen()).To(BeEmpty())
+ Expect(clients.addressesSeen()).To(BeEmpty())
+ })
+
+ It("returns the reservation FindAndLockNodeWithModel took", func() {
+ // Held rather than returned, the row's in_flight never reaches 0
+ // and no eviction query can ever select it, so the replica slot and
+ // its VRAM are pinned for the life of the row.
+ Expect(warm()).To(BeNil())
+ registry.mu.Lock()
+ defer registry.mu.Unlock()
+ Expect(registry.decrementCalled).To(HaveKeyWithValue("node-1:m", 1))
+ })
+
+ It("leaves the row in place", func() {
+ // Deliberately unlike the sibling !alive branch, which removes the
+ // row. A dead backend has been observed dead; this row has been
+ // observed to be unreadable, which says nothing about whether a
+ // process is running on that worker. It is also the last record
+ // that one may be: the acknowledged stop path matches on
+ // ExpectedAddress, so a stop for an empty one is refused by the
+ // worker, and deleting the row here would free the replica slot for
+ // a second copy of the same model while the first one, if it
+ // exists, keeps its VRAM. The cost of keeping it is one lock and
+ // decrement per request before the cold load, and a row an operator
+ // can see; the cost of removing it is an orphan nothing points at.
+ Expect(warm()).To(BeNil())
+ Expect(registry.removedModels()).To(BeEmpty())
+ })
+ })
+
+ It("routes normally once the row names one", func() {
+ // The control. Without it every assertion above would also pass on a
+ // warm path that declined everything.
+ registry.nodeModel = &NodeModel{NodeID: node.ID, ModelName: "m", ReplicaIndex: 0, WorkerLocalAddress: "127.0.0.1:50052"}
+ Expect(warm()).ToNot(BeNil())
+ Expect(clients.addressesSeen()).To(ContainElement("127.0.0.1:50052"))
+ registry.mu.Lock()
+ defer registry.mu.Unlock()
+ Expect(registry.decrementCalled).ToNot(HaveKey("node-1:m"))
+ })
+})
diff --git a/core/services/nodes/router_unreachable_worker_test.go b/core/services/nodes/router_unreachable_worker_test.go
new file mode 100644
index 000000000000..6a5a4501be60
--- /dev/null
+++ b/core/services/nodes/router_unreachable_worker_test.go
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: MIT
+
+package nodes
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
+)
+
+// unreachableClientFactory cannot build a client for any node, standing in for
+// a frontend whose worker tunnel dialer is missing or broken. This is the
+// BOOT-TIME half of unroutability.
+type unreachableClientFactory struct{}
+
+func (unreachableClientFactory) NewClientForNode(_, _ string, _ bool) (grpc.Backend, error) {
+ return nil, ErrNoWorkerDialer
+}
+
+// deadDialFactory builds clients normally and fails the DIAL, which is the
+// RUNNING half and by far the likelier one: the factory only fails when the
+// wiring is absent, while the dial fails whenever the replica holding a
+// worker's tunnel is momentarily unreachable, which one frontend restart
+// produces for every worker that replica holds.
+func deadDialFactory(cause error) BackendClientFactory {
+ GinkgoHelper()
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(context.Context, string) (net.Conn, error) { return nil, cause }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ return f
+}
+
+var _ = Describe("routing when the worker cannot be reached at all", func() {
+ // The catastrophe this phase exists to prevent, at the router. A frontend
+ // that cannot reach a worker has learned NOTHING about that worker's
+ // backends. Treating it as a failed health probe would reap the replica row
+ // for every model in the deployment while those models carried on running,
+ // and the reap is silent: the row is simply deleted and the model
+ // cold-loaded somewhere else.
+ loadedReg := func() *fakeModelRouter {
+ node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
+ nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
+ return &fakeModelRouter{
+ findAndLockNode: node,
+ findAndLockNM: nm,
+ loadedReplicaStatsByName: map[string][]ReplicaCandidate{"m": {{NodeID: "X", InFlight: 0}}},
+ }
+ }
+
+ It("never removes the replica row of a worker it merely cannot reach", func() {
+ reg := loadedReg()
+ router := NewSmartRouter(reg, SmartRouterOptions{
+ Unloader: &fakeUnloader{},
+ ClientFactory: unreachableClientFactory{},
+ })
+
+ _, err := router.Route(context.Background(), "m", "models/m.gguf", "llama-cpp", "", nil, false)
+ // The request cannot be served, which is right and loud.
+ Expect(err).To(HaveOccurred())
+ // What must NOT have happened is the replica being reclaimed.
+ Expect(reg.removeCalls).To(BeEmpty(),
+ "a worker this frontend cannot reach must never have its loaded models reaped")
+ })
+
+ It("releases the routing reservation it took before giving up", func() {
+ // FindAndLockNodeWithModel increments in_flight as a reservation. A
+ // path that returns without releasing it leaves the replica looking
+ // permanently busy, which is how a warm replica stops being picked at
+ // all.
+ reg := loadedReg()
+ router := NewSmartRouter(reg, SmartRouterOptions{
+ Unloader: &fakeUnloader{},
+ ClientFactory: unreachableClientFactory{},
+ })
+
+ _, err := router.Route(context.Background(), "m", "models/m.gguf", "llama-cpp", "", nil, false)
+ Expect(err).To(HaveOccurred())
+ Expect(reg.decrementCalls).To(ContainElement("X:m"))
+ })
+})
+
+var _ = Describe("routing when the worker's tunnel dial fails", func() {
+ // The reviewer's spec. It is the boundary test: the factory succeeds, the
+ // gRPC client is built, and the DIAL fails underneath with a
+ // cluster.ErrNoRoute. gRPC flattens that into codes.Unavailable, which is
+ // also what a dead backend produces, so without a way to carry the
+ // distinction past the package boundary a peer link blip is read as a dead
+ // process and the replica row is deleted after ONE miss.
+ loadedReg := func() *fakeModelRouter {
+ node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
+ nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
+ return &fakeModelRouter{
+ findAndLockNode: node,
+ findAndLockNM: nm,
+ loadedReplicaStatsByName: map[string][]ReplicaCandidate{"m": {{NodeID: "X", InFlight: 0}}},
+ }
+ }
+
+ route := func(reg *fakeModelRouter, cause error) error {
+ router := NewSmartRouter(reg, SmartRouterOptions{
+ Unloader: &fakeUnloader{},
+ ClientFactory: deadDialFactory(cause),
+ })
+ _, err := router.Route(context.Background(), "m", "models/m.gguf", "llama-cpp", "", nil, false)
+ return err
+ }
+
+ It("never reaps a replica whose OWNER replica is unreachable", func() {
+ reg := loadedReg()
+ Expect(route(reg, fmt.Errorf("through replica %q: %w: %w", "peer-2", cluster.ErrNoRoute, cluster.ErrPeerUnreachable))).To(HaveOccurred())
+ Expect(reg.removeCalls).To(BeEmpty(),
+ "a worker whose OWNER replica is unreachable must never have its loaded models reaped")
+ })
+
+ It("never reaps a replica that has not dialled its tunnel yet", func() {
+ // The rolling-upgrade case end to end. A frontend-first upgrade puts
+ // every not-yet-restarted worker here at once, and every one of them is
+ // heartbeating and serving while it happens.
+ reg := loadedReg()
+ Expect(route(reg, fmt.Errorf("reaching node %q: %w", "X", cluster.ErrNoRoute))).To(HaveOccurred())
+ Expect(reg.removeCalls).To(BeEmpty())
+ })
+
+ It("still releases the reservation it took", func() {
+ reg := loadedReg()
+ Expect(route(reg, fmt.Errorf("%w", cluster.ErrNoRoute))).To(HaveOccurred())
+ Expect(reg.decrementCalls).To(ContainElement("X:m"))
+ })
+
+ It("carries the cluster condition all the way across the package boundary", func() {
+ // Not just "something failed": the specific reason survives gRPC, which
+ // is what makes the five conditions usable on this side. If this ever
+ // reduces to a bare code, the consumers above are guessing again.
+ f := deadDialFactory(fmt.Errorf("through replica %q: %w: %w", "peer-2", cluster.ErrNoRoute, cluster.ErrPeerUnreachable))
+ client, err := f.NewClientForNode("X", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, _ = client.HealthCheck(ctx)
+
+ unreached := unroutable(client)
+ Expect(unreached).To(MatchError(ErrWorkerUnroutable))
+ Expect(unreached).To(MatchError(cluster.ErrNoRoute))
+ Expect(unreached).To(MatchError(cluster.ErrPeerUnreachable))
+ // And never absence, at either end of the trip.
+ Expect(unreached).ToNot(MatchError(cluster.ErrNoConnection))
+ Expect(unreached).ToNot(MatchError(cluster.ErrInstanceNotFound))
+ })
+
+ It("reports nothing for a client whose dial succeeded", func() {
+ // The other direction, so the seam cannot pass by always saying yes: a
+ // backend that genuinely died must still be reapable.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = listener.Close() })
+
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return func(ctx context.Context, _ string) (net.Conn, error) {
+ return d.DialContext(ctx, "tcp", listener.Addr().String())
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ client, err := f.NewClientForNode("X", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ // The listener accepts and speaks no gRPC, so the RPC fails while the
+ // DIAL succeeds. That is exactly a dead-ish backend on a reachable
+ // worker, and it must not read as unroutable.
+ _, _ = client.HealthCheck(ctx)
+ Expect(unroutable(client)).To(BeNil())
+ })
+})
diff --git a/core/services/nodes/unloader.go b/core/services/nodes/unloader.go
index 460be8acf613..7c2bd76e07a5 100644
--- a/core/services/nodes/unloader.go
+++ b/core/services/nodes/unloader.go
@@ -2,22 +2,20 @@ package nodes
import (
"context"
- "encoding/json"
"errors"
"fmt"
- "strings"
"time"
- "github.com/nats-io/nats.go"
-
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
-// NodeCommandSender abstracts NATS-based commands to worker nodes.
-// Used by HTTP endpoint handlers to avoid coupling to the concrete RemoteUnloaderAdapter.
+// NodeCommandSender abstracts the control commands a frontend issues to a
+// worker node. They travel over the worker's tunnel as HTTP under
+// workerctl.Prefix; see RemoteUnloaderAdapter.
//
// InstallBackend is idempotent: the worker short-circuits if the backend is
// already running for the requested (modelID, replica) slot. Routine model
@@ -27,8 +25,8 @@ import (
// every live process for the backend, re-pulls the gallery artifact, and
// replies. Caller (DistributedBackendManager.UpgradeBackend) handles
// rolling-update fallback to the legacy install Force=true path on
-// nats.ErrNoResponders for old workers that don't subscribe to the new
-// backend.upgrade subject.
+// ErrWorkerControlUnsupported, which is what a worker older than the
+// backend.upgrade verb answers.
type NodeCommandSender interface {
InstallBackend(nodeID, backendType, modelID, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendInstallReply, error)
UpgradeBackend(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendUpgradeReply, error)
@@ -36,34 +34,40 @@ type NodeCommandSender interface {
ListBackends(nodeID string) (*messaging.BackendListReply, error)
StopBackend(nodeID, backend string) error
UnloadModelOnNode(nodeID, modelName string) error
- // PingNode reports whether the node is still subscribed on the bus. It
- // returns nats.ErrNoResponders when nothing answers for the node, which is
- // the only condition callers may read as "this node cannot be given work".
- PingNode(nodeID string) error
}
-// RemoteUnloaderAdapter implements NodeCommandSender and model.RemoteModelUnloader
-// by publishing NATS events for backend process lifecycle. The worker process
-// subscribes and handles the actual process start/stop.
+// RemoteUnloaderAdapter implements NodeCommandSender and
+// model.RemoteModelUnloader by issuing control RPCs to the worker over its
+// tunnel. The worker serves them on the loopback HTTP server it already runs
+// (see core/services/worker/control_routes.go) and handles the actual process
+// start/stop.
+//
+// This mirrors the local ModelLoader's startProcess()/deleteProcess() but for
+// remote nodes.
//
-// This mirrors the local ModelLoader's startProcess()/deleteProcess() but
-// over NATS for remote nodes.
+// One verb is still carried by the bus, and only for one KIND of node:
+// backend.stop to an AGENT node. Agent workers hold no tunnel yet, so they have
+// nothing to serve a control route on, and they subscribe to
+// nodes..backend.stop to drop cached MCP sessions. Removing that publish
+// would strand them; see stopBackend.
type RemoteUnloaderAdapter struct {
registry ModelLocator
nats messaging.MessagingClient
+ control *ControlClient
installTimeout time.Duration
upgradeTimeout time.Duration
}
-// NewRemoteUnloaderAdapter creates a new adapter. installTimeout and
-// upgradeTimeout govern the NATS request-reply deadlines for backend.install
-// and backend.upgrade respectively. Use
-// DistributedConfig.BackendInstallTimeoutOrDefault() /
+// NewRemoteUnloaderAdapter creates a new adapter. control carries every verb
+// except backend.stop to an agent node, which stays on nats. installTimeout and
+// upgradeTimeout bound the backend.install and backend.upgrade RPCs
+// respectively; use DistributedConfig.BackendInstallTimeoutOrDefault() /
// BackendUpgradeTimeoutOrDefault() at construction.
-func NewRemoteUnloaderAdapter(registry ModelLocator, nats messaging.MessagingClient, installTimeout, upgradeTimeout time.Duration) *RemoteUnloaderAdapter {
+func NewRemoteUnloaderAdapter(registry ModelLocator, nats messaging.MessagingClient, control *ControlClient, installTimeout, upgradeTimeout time.Duration) *RemoteUnloaderAdapter {
return &RemoteUnloaderAdapter{
registry: registry,
nats: nats,
+ control: control,
installTimeout: installTimeout,
upgradeTimeout: upgradeTimeout,
}
@@ -93,6 +97,13 @@ const exactModelStopTimeout = 10 * time.Second
// StopModelReplica stops only the process represented by replica. Configuration
// cleanup intentionally has no backend.stop fallback: an old worker that does
// not understand this request leaves the quarantine row for a later retry.
+//
+// The caller's context is carried into the RPC rather than run alongside it on
+// a goroutine, which is what the request/reply carrier needed because it took a
+// timeout and not a context. Abandoning the request is safe: the worker's
+// model.stop handler deliberately drops the caller's context and runs the stop
+// to completion, so a frontend that gives up cannot leave a half-stopped
+// process or an unreturned port behind.
func (a *RemoteUnloaderAdapter) StopModelReplica(ctx context.Context, nodeID string, replica NodeModel, force bool) (messaging.ModelStopReply, error) {
if ctx == nil {
ctx = context.Background()
@@ -100,35 +111,23 @@ func (a *RemoteUnloaderAdapter) StopModelReplica(ctx context.Context, nodeID str
ctx, cancel := context.WithTimeout(ctx, exactModelStopTimeout)
defer cancel()
- type result struct {
- reply *messaging.ModelStopReply
- err error
- }
- done := make(chan result, 1)
- go func() {
- reply, err := messaging.RequestJSON[messaging.ModelStopRequest, messaging.ModelStopReply](a.nats, messaging.SubjectNodeModelStop(nodeID), messaging.ModelStopRequest{
- ModelName: replica.ModelName,
- ProcessKey: model.BackendProcessKey(replica.ModelName, replica.ReplicaIndex),
- ExpectedAddress: replica.Address,
- Force: force,
- ConfigRevision: replica.ConfigRevision,
- }, exactModelStopTimeout)
- done <- result{reply: reply, err: err}
- }()
-
- select {
- case <-ctx.Done():
- return messaging.ModelStopReply{}, ctx.Err()
- case result := <-done:
- if result.err != nil {
- return messaging.ModelStopReply{}, result.err
- }
- return *result.reply, nil
+ var reply messaging.ModelStopReply
+ err := a.control.Call(ctx, nodeID, workerctl.PathModelStop, messaging.ModelStopRequest{
+ ModelName: replica.ModelName,
+ ProcessKey: model.BackendProcessKey(replica.ModelName, replica.ReplicaIndex),
+ ExpectedAddress: replica.WorkerLocalAddress,
+ Force: force,
+ ConfigRevision: replica.ConfigRevision,
+ }, &reply)
+ if err != nil {
+ return messaging.ModelStopReply{}, err
}
+ return reply, nil
}
-// UnloadRemoteModel finds the node(s) hosting the given model and tells them
-// to stop their backend process via NATS backend.stop event.
+// UnloadRemoteModel finds the node(s) hosting the given model and tells each
+// to stop its backend process. The carrier is decided per node by its type,
+// which is why stopBackend takes one: see stopBackend.
// The worker process handles a bounded Free() followed by process termination;
// forced shutdown skips Free().
// This is called by ModelLoader.deleteProcess() when process == nil (remote model).
@@ -173,8 +172,8 @@ func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, mo
var unloadErr error
for _, node := range nodes {
- xlog.Info("Sending NATS backend.stop to node", "model", modelName, "node", node.Name, "nodeID", node.ID, "force", force)
- if err := a.stopBackend(node.ID, modelName, force); err != nil {
+ xlog.Info("Sending backend.stop to node", "model", modelName, "node", node.Name, "nodeID", node.ID, "force", force)
+ if err := a.stopBackend(ctx, node.ID, node.NodeType, modelName, force); err != nil {
xlog.Warn("Failed to send backend.stop", "node", node.Name, "error", err)
unloadErr = errors.Join(unloadErr, fmt.Errorf("stopping model on node %s: %w", node.ID, err))
continue
@@ -189,7 +188,7 @@ func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, mo
return unloadErr
}
-// InstallBackend sends a backend.install request-reply to a worker node.
+// InstallBackend asks a worker node to install a backend and start its process.
// Idempotent on the worker: if the (modelID, replica) process is already
// running, the worker short-circuits and returns its address; if the binary
// is on disk, the worker just spawns a process; only a missing binary
@@ -201,23 +200,24 @@ func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, mo
// case on slow links (Jetson Wi-Fi, multi-GB CUDA images) while still
// failing fast enough to surface real worker hangs.
//
-// For force-reinstall (admin-driven Upgrade), use UpgradeBackend instead -
-// it lives on a different NATS subject so it cannot head-of-line-block
-// routine load traffic on the same worker.
+// Progress needs no subscription and no window to miss events in: the worker
+// writes its download ticks into THIS response ahead of the terminal reply, so
+// there is nothing to arrange before the request is sent.
+//
+// For force-reinstall (admin-driven Upgrade), use UpgradeBackend instead.
func (a *RemoteUnloaderAdapter) InstallBackend(
nodeID, backendType, modelID, galleriesJSON, uri, name, alias string,
replicaIndex int,
opID string,
onProgress func(messaging.BackendInstallProgressEvent),
) (*messaging.BackendInstallReply, error) {
- subject := messaging.SubjectNodeBackendInstall(nodeID)
- xlog.Info("Sending NATS backend.install", "nodeID", nodeID, "backend", backendType, "modelID", modelID, "replica", replicaIndex, "opID", opID)
+ xlog.Info("Sending backend.install", "nodeID", nodeID, "backend", backendType, "modelID", modelID, "replica", replicaIndex, "opID", opID)
- // Subscribe to the per-op progress subject BEFORE publishing the install
- // request so we don't miss early events.
- sub := a.subscribeProgress(nodeID, opID, onProgress)
+ ctx, cancel := context.WithTimeout(context.Background(), a.installTimeout)
+ defer cancel()
- reply, err := messaging.RequestJSON[messaging.BackendInstallRequest, messaging.BackendInstallReply](a.nats, subject, messaging.BackendInstallRequest{
+ var reply messaging.BackendInstallReply
+ err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendInstall, messaging.BackendInstallRequest{
Backend: backendType,
ModelID: modelID,
BackendGalleries: galleriesJSON,
@@ -226,75 +226,34 @@ func (a *RemoteUnloaderAdapter) InstallBackend(
Alias: alias,
ReplicaIndex: int32(replicaIndex),
OpID: opID,
- }, a.installTimeout)
-
- if sub != nil {
- if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
- xlog.Warn("Failed to unsubscribe from backend install progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
- }
- }
-
- if err != nil && isNATSTimeout(err) {
- return nil, fmt.Errorf("%w (subject=%s nodeID=%s backend=%s): %v",
- galleryop.ErrWorkerStillInstalling, subject, nodeID, backendType, err)
- }
- return reply, err
-}
-
-// subscribeProgress subscribes to the per-op backend-install progress subject
-// so the master can stream per-node download ticks while a worker installs or
-// upgrades. Returns nil (and subscribes to nothing) when onProgress is nil or
-// opID is empty — the reconciler-driven retry path and legacy callers stay
-// silent at no cost. Shared by InstallBackend, UpgradeBackend, and the legacy
-// force-install fallback: an upgrade is a force-reinstall, so it reuses the
-// install-progress subject rather than minting a new one (no new NATS
-// permission, no new rolling-update compat surface). Caller must Unsubscribe
-// the returned subscription after the request completes.
-func (a *RemoteUnloaderAdapter) subscribeProgress(nodeID, opID string, onProgress func(messaging.BackendInstallProgressEvent)) messaging.Subscription {
- if onProgress == nil || opID == "" {
- return nil
- }
- progressSubject := messaging.SubjectNodeBackendInstallProgress(nodeID, opID)
- s, subErr := a.nats.Subscribe(progressSubject, func(raw []byte) {
- var ev messaging.BackendInstallProgressEvent
- if err := json.Unmarshal(raw, &ev); err != nil {
- xlog.Debug("malformed backend progress event", "subject", progressSubject, "error", err)
- return
+ }, &reply, onProgress)
+ if err != nil {
+ if isRequestTimeout(err) {
+ return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
+ galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
}
- // Goroutine guard: a slow onProgress callback must not stall the NATS
- // reader thread. Events spawn one goroutine each, so ordering at the
- // consumer is best-effort; the worker debounces to ~250ms which dwarfs
- // goroutine scheduling jitter, and its final Flush() is the terminal tick.
- go onProgress(ev)
- })
- if subErr != nil {
- xlog.Warn("Failed to subscribe to backend progress subject; proceeding without progress streaming",
- "subject", progressSubject, "error", subErr)
- return nil
+ return nil, err
}
- return s
+ return &reply, nil
}
-// UpgradeBackend sends a backend.upgrade request-reply to a worker node.
+// UpgradeBackend asks a worker node to force-reinstall a backend.
// The worker stops every live process for this backend, force-reinstalls
// from the gallery (overwriting the on-disk artifact), and replies. The
// next routine InstallBackend call spawns a fresh process with the new
// binary - upgrade itself does not start a process.
//
-// When opID is non-empty and onProgress is set, the master subscribes to the
-// per-op progress subject before firing the request so a long force-reinstall
-// streams per-node download ticks instead of blocking opaque at progress 0.
-//
// Timeout: configured via DistributedConfig.BackendUpgradeTimeoutOrDefault
// (default 15m). Real-world worst case observed: 8-10 minutes for large
// CUDA-l4t backend images on Jetson over WiFi.
func (a *RemoteUnloaderAdapter) UpgradeBackend(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendUpgradeReply, error) {
- subject := messaging.SubjectNodeBackendUpgrade(nodeID)
- xlog.Info("Sending NATS backend.upgrade", "nodeID", nodeID, "backend", backendType, "replica", replicaIndex, "opID", opID)
+ xlog.Info("Sending backend.upgrade", "nodeID", nodeID, "backend", backendType, "replica", replicaIndex, "opID", opID)
- sub := a.subscribeProgress(nodeID, opID, onProgress)
+ ctx, cancel := context.WithTimeout(context.Background(), a.upgradeTimeout)
+ defer cancel()
- reply, err := messaging.RequestJSON[messaging.BackendUpgradeRequest, messaging.BackendUpgradeReply](a.nats, subject, messaging.BackendUpgradeRequest{
+ var reply messaging.BackendUpgradeReply
+ err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendUpgrade, messaging.BackendUpgradeRequest{
Backend: backendType,
BackendGalleries: galleriesJSON,
URI: uri,
@@ -302,37 +261,31 @@ func (a *RemoteUnloaderAdapter) UpgradeBackend(nodeID, backendType, galleriesJSO
Alias: alias,
ReplicaIndex: int32(replicaIndex),
OpID: opID,
- }, a.upgradeTimeout)
-
- if sub != nil {
- if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
- xlog.Warn("Failed to unsubscribe from backend upgrade progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
+ }, &reply, onProgress)
+ if err != nil {
+ if isRequestTimeout(err) {
+ return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
+ galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
}
+ return nil, err
}
-
- if err != nil && isNATSTimeout(err) {
- return nil, fmt.Errorf("%w (subject=%s nodeID=%s backend=%s): %v",
- galleryop.ErrWorkerStillInstalling, subject, nodeID, backendType, err)
- }
- if err == nil {
- a.dropStoppedReplicaRows(nodeID, "backend.upgrade", backendType, reply.StoppedProcessKeys, reply.ReportsStoppedProcesses)
- }
- return reply, err
+ a.dropStoppedReplicaRows(nodeID, "backend.upgrade", backendType, reply.StoppedProcessKeys, reply.ReportsStoppedProcesses)
+ return &reply, nil
}
// installWithForceFallback is the rolling-update fallback used by
-// DistributedBackendManager.UpgradeBackend when backend.upgrade returns
-// nats.ErrNoResponders (the worker is on a pre-2026-05-08 build that
-// doesn't subscribe to the new subject). It re-fires the legacy
-// backend.install with Force=true. Drop this once every worker is on
-// 2026-05-08 or newer.
+// DistributedBackendManager.UpgradeBackend when backend.upgrade reports that
+// the worker does not serve that verb (a pre-2026-05-08 build). It re-fires
+// the legacy backend.install with Force=true. Drop this once every worker is
+// on 2026-05-08 or newer.
func (a *RemoteUnloaderAdapter) installWithForceFallback(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendInstallReply, error) {
- subject := messaging.SubjectNodeBackendInstall(nodeID)
xlog.Warn("Falling back to legacy backend.install Force=true (old worker)", "nodeID", nodeID, "backend", backendType)
- sub := a.subscribeProgress(nodeID, opID, onProgress)
+ ctx, cancel := context.WithTimeout(context.Background(), a.upgradeTimeout)
+ defer cancel()
- reply, err := messaging.RequestJSON[messaging.BackendInstallRequest, messaging.BackendInstallReply](a.nats, subject, messaging.BackendInstallRequest{
+ var reply messaging.BackendInstallReply
+ err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendInstall, messaging.BackendInstallRequest{
Backend: backendType,
BackendGalleries: galleriesJSON,
URI: uri,
@@ -341,108 +294,128 @@ func (a *RemoteUnloaderAdapter) installWithForceFallback(nodeID, backendType, ga
ReplicaIndex: int32(replicaIndex),
Force: true,
OpID: opID,
- }, a.upgradeTimeout)
-
- if sub != nil {
- if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
- xlog.Warn("Failed to unsubscribe from legacy backend install progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
+ }, &reply, onProgress)
+ if err != nil {
+ if isRequestTimeout(err) {
+ return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
+ galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
}
+ return nil, err
}
-
- if err != nil && isNATSTimeout(err) {
- return nil, fmt.Errorf("%w (subject=%s nodeID=%s backend=%s): %v",
- galleryop.ErrWorkerStillInstalling, subject, nodeID, backendType, err)
- }
- return reply, err
+ return &reply, nil
}
-// ListBackends queries a worker node for its installed backends via NATS request-reply.
+// Control-RPC budgets. Each is the deadline the corresponding NATS
+// request/reply carried, kept unchanged so this cutover changes the carrier and
+// not how long the frontend waits.
+const (
+ backendListTimeout = 30 * time.Second
+ modelsRunningTimeout = 10 * time.Second
+ backendStopTimeout = 30 * time.Second
+ backendDeleteTimeout = 2 * time.Minute
+ modelUnloadTimeout = 30 * time.Second
+ modelDeleteTimeout = 30 * time.Second
+ nodeStopTimeout = 30 * time.Second
+)
+
+// ListBackends queries a worker node for its installed backends.
func (a *RemoteUnloaderAdapter) ListBackends(nodeID string) (*messaging.BackendListReply, error) {
- subject := messaging.SubjectNodeBackendList(nodeID)
- xlog.Debug("Sending NATS backend.list", "nodeID", nodeID)
+ xlog.Debug("Sending backend.list", "nodeID", nodeID)
- return messaging.RequestJSON[messaging.BackendListRequest, messaging.BackendListReply](a.nats, subject, messaging.BackendListRequest{}, 30*time.Second)
-}
+ ctx, cancel := context.WithTimeout(context.Background(), backendListTimeout)
+ defer cancel()
-// PingNode checks that a worker still has a live subscription on the bus.
-//
-// A node's status in the database comes from its HTTP heartbeat, which is a
-// separate channel from NATS. A worker that has died stops answering on NATS
-// at once but keeps its healthy status until the heartbeat ages out, so the
-// scheduler could pick a node that could not be given work and the request
-// failed with "no responders available".
-//
-// The subject asked has to be one every worker in the fleet subscribes to, or
-// this check condemns the workers that do not. models.running was the obvious
-// choice and the wrong one: it arrived in 4.6, so a 4.5 worker that is alive
-// and serving never answers it, and a model pinned to that node could never be
-// scheduled. backend.list has been part of the worker protocol far longer, so
-// it is the safer question to ask.
-//
-// A worker that answers anything is alive. Only when every subject reports no
-// responders is the node treated as absent, so adding a newer subject here can
-// never condemn an older worker.
-func (a *RemoteUnloaderAdapter) PingNode(nodeID string) error {
- subjects := []string{
- messaging.SubjectNodeBackendList(nodeID),
- messaging.SubjectNodeModelsRunning(nodeID),
- }
- var lastErr error
- for _, subject := range subjects {
- _, err := messaging.RequestJSON[messaging.BackendListRequest, messaging.BackendListReply](
- a.nats, subject, messaging.BackendListRequest{}, 5*time.Second)
- if err == nil {
- return nil
- }
- if !errors.Is(err, nats.ErrNoResponders) {
- // Reached someone, or failed for a reason that is not absence.
- // Either way the node is not proven gone.
- return nil
- }
- lastErr = err
+ var reply messaging.BackendListReply
+ if err := a.control.Call(ctx, nodeID, workerctl.PathBackendList, messaging.BackendListRequest{}, &reply); err != nil {
+ return nil, err
}
- return lastErr
+ return &reply, nil
}
// ListRunningModels asks a worker node which model backend processes it
-// currently has running, via NATS request-reply.
+// currently has running.
//
// The timeout is short on purpose: the worker answers straight out of its
// in-memory process table, so a slow reply means the worker itself is in
// trouble, and the caller treats no-answer as "don't know" rather than as
// "nothing running".
func (a *RemoteUnloaderAdapter) ListRunningModels(nodeID string) (*messaging.ModelsRunningReply, error) {
- subject := messaging.SubjectNodeModelsRunning(nodeID)
- return messaging.RequestJSON[messaging.ModelsRunningRequest, messaging.ModelsRunningReply](
- a.nats, subject, messaging.ModelsRunningRequest{}, 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), modelsRunningTimeout)
+ defer cancel()
+
+ var reply messaging.ModelsRunningReply
+ if err := a.control.Call(ctx, nodeID, workerctl.PathModelsRunning, messaging.ModelsRunningRequest{}, &reply); err != nil {
+ return nil, err
+ }
+ return &reply, nil
}
// StopBackend tells a worker node to stop a specific gRPC backend process.
// If backend is empty, the worker stops ALL backends.
// The node stays registered and can receive another InstallBackend later.
func (a *RemoteUnloaderAdapter) StopBackend(nodeID, backend string) error {
- return a.stopBackend(nodeID, backend, false)
+ ctx, cancel := context.WithTimeout(context.Background(), backendStopTimeout)
+ defer cancel()
+ return a.stopBackend(ctx, nodeID, a.nodeTypeOf(ctx, nodeID), backend, false)
}
-func (a *RemoteUnloaderAdapter) stopBackend(nodeID, backend string, force bool) error {
- subject := messaging.SubjectNodeBackendStop(nodeID)
- if backend == "" && !force {
- return a.nats.Publish(subject, nil)
+// nodeTypeOf answers which KIND of worker a node id names, so stopBackend can
+// pick the carrier that node actually listens on.
+//
+// A lookup that fails answers NodeTypeBackend, matching the column's own
+// default and the "empty means backend" reading every other node-type branch in
+// this package takes. The failure directions are not symmetric: sending a
+// backend node's stop over the bus is silently lost, because nothing subscribes
+// to it any more, while sending an agent node's stop over the tunnel returns an
+// error the caller sees.
+func (a *RemoteUnloaderAdapter) nodeTypeOf(ctx context.Context, nodeID string) string {
+ if a.registry == nil {
+ return NodeTypeBackend
+ }
+ node, err := a.registry.Get(ctx, nodeID)
+ if err != nil || node == nil {
+ xlog.Debug("Could not resolve node type for a backend stop; assuming a backend worker",
+ "nodeID", nodeID, "error", err)
+ return NodeTypeBackend
}
- return a.nats.Publish(subject, messaging.BackendStopRequest{Backend: backend, Force: force})
+ return node.NodeType
+}
+
+// stopBackend sends one backend.stop, over the carrier that kind of worker
+// listens on.
+//
+// An AGENT node keeps the bus. It holds no tunnel, so it has no control route
+// to serve, and it subscribes to nodes..backend.stop to drop the MCP
+// sessions cached for a backend that is going away. This is the one verb of the
+// ten that is split rather than moved, and the split is the honest intermediate
+// state until agent workers hold tunnels too.
+func (a *RemoteUnloaderAdapter) stopBackend(ctx context.Context, nodeID, nodeType, backend string, force bool) error {
+ if nodeType == NodeTypeAgent {
+ subject := messaging.SubjectNodeBackendStop(nodeID)
+ if backend == "" && !force {
+ return a.nats.Publish(subject, nil)
+ }
+ return a.nats.Publish(subject, messaging.BackendStopRequest{Backend: backend, Force: force})
+ }
+ // An empty Backend is what the worker reads as "stop everything", the same
+ // meaning the bus carried as an empty payload; see decodeBackendStopRequest.
+ return a.control.Call(ctx, nodeID, workerctl.PathBackendStop,
+ messaging.BackendStopRequest{Backend: backend, Force: force}, nil)
}
// DeleteBackend tells a worker node to delete a backend (stop + remove files).
func (a *RemoteUnloaderAdapter) DeleteBackend(nodeID, backendName string) (*messaging.BackendDeleteReply, error) {
- subject := messaging.SubjectNodeBackendDelete(nodeID)
- xlog.Info("Sending NATS backend.delete", "nodeID", nodeID, "backend", backendName)
+ xlog.Info("Sending backend.delete", "nodeID", nodeID, "backend", backendName)
- reply, err := messaging.RequestJSON[messaging.BackendDeleteRequest, messaging.BackendDeleteReply](a.nats, subject, messaging.BackendDeleteRequest{Backend: backendName}, 2*time.Minute)
- if err != nil {
- return reply, err
+ ctx, cancel := context.WithTimeout(context.Background(), backendDeleteTimeout)
+ defer cancel()
+
+ var reply messaging.BackendDeleteReply
+ if err := a.control.Call(ctx, nodeID, workerctl.PathBackendDelete, messaging.BackendDeleteRequest{Backend: backendName}, &reply); err != nil {
+ return nil, err
}
a.dropStoppedReplicaRows(nodeID, "backend.delete", backendName, reply.StoppedProcessKeys, reply.ReportsStoppedProcesses)
- return reply, nil
+ return &reply, nil
}
// dropStoppedReplicaRows removes the NodeModel rows addressing processes a
@@ -491,11 +464,13 @@ func (a *RemoteUnloaderAdapter) dropStoppedReplicaRows(nodeID, op, backendName s
// UnloadModelOnNode sends a model.unload request to a specific node.
// The worker calls gRPC Free() to release GPU memory.
func (a *RemoteUnloaderAdapter) UnloadModelOnNode(nodeID, modelName string) error {
- subject := messaging.SubjectNodeModelUnload(nodeID)
- xlog.Info("Sending NATS model.unload", "nodeID", nodeID, "model", modelName)
+ xlog.Info("Sending model.unload", "nodeID", nodeID, "model", modelName)
- reply, err := messaging.RequestJSON[messaging.ModelUnloadRequest, messaging.ModelUnloadReply](a.nats, subject, messaging.ModelUnloadRequest{ModelName: modelName}, 30*time.Second)
- if err != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), modelUnloadTimeout)
+ defer cancel()
+
+ var reply messaging.ModelUnloadReply
+ if err := a.control.Call(ctx, nodeID, workerctl.PathModelUnload, messaging.ModelUnloadRequest{ModelName: modelName}, &reply); err != nil {
return err
}
if !reply.Success {
@@ -507,18 +482,20 @@ func (a *RemoteUnloaderAdapter) UnloadModelOnNode(nodeID, modelName string) erro
// DeleteModelFiles sends model.delete to all nodes that have the model cached.
// This removes model files from worker disks.
func (a *RemoteUnloaderAdapter) DeleteModelFiles(modelName string) error {
- nodes, err := a.registry.FindNodesWithModel(context.Background(), modelName)
+ ctx, cancel := context.WithTimeout(context.Background(), modelDeleteTimeout)
+ defer cancel()
+
+ nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
if err != nil || len(nodes) == 0 {
xlog.Debug("No nodes with model for file deletion", "model", modelName)
return nil
}
for _, node := range nodes {
- subject := messaging.SubjectNodeModelDelete(node.ID)
- xlog.Info("Sending NATS model.delete", "nodeID", node.ID, "model", modelName)
+ xlog.Info("Sending model.delete", "nodeID", node.ID, "model", modelName)
- reply, err := messaging.RequestJSON[messaging.ModelDeleteRequest, messaging.ModelDeleteReply](a.nats, subject, messaging.ModelDeleteRequest{ModelName: modelName}, 30*time.Second)
- if err != nil {
+ var reply messaging.ModelDeleteReply
+ if err := a.control.Call(ctx, node.ID, workerctl.PathModelDelete, messaging.ModelDeleteRequest{ModelName: modelName}, &reply); err != nil {
xlog.Warn("model.delete failed on node", "node", node.Name, "error", err)
continue
}
@@ -531,17 +508,22 @@ func (a *RemoteUnloaderAdapter) DeleteModelFiles(modelName string) error {
// StopNode tells a worker node to shut down entirely (deregister + exit).
func (a *RemoteUnloaderAdapter) StopNode(nodeID string) error {
- subject := messaging.SubjectNodeStop(nodeID)
- return a.nats.Publish(subject, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), nodeStopTimeout)
+ defer cancel()
+ return a.control.Call(ctx, nodeID, workerctl.PathNodeStop, struct{}{}, nil)
}
-// isNATSTimeout returns true if err looks like a NATS request-reply timeout.
-// nats.ErrTimeout is the canonical sentinel; context.DeadlineExceeded can
-// also surface depending on the client's path; we accept both, plus a
-// string-match fallback for clients that return a bare error.
-func isNATSTimeout(err error) bool {
- if errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) {
- return true
- }
- return err != nil && strings.Contains(err.Error(), "nats: timeout")
+// isRequestTimeout reports whether a control RPC ended because its budget ran
+// out rather than because the worker said anything.
+//
+// context.DeadlineExceeded is the ONE signal, and it is matchable because
+// controlFailure wraps the caller's own ctx.Err(). The bus sentinel it used to
+// accept alongside is gone with the bus: every verb this adapter sends now
+// travels over the worker's tunnel, so a nats.ErrTimeout could only arrive from
+// a carrier nothing here uses. The string match that carrier needed is
+// deliberately not reproduced either, in any spelling: a message that merely
+// quotes a timeout is not one, and a worker error that happened to contain the
+// phrase would be reported as still-installing forever.
+func isRequestTimeout(err error) bool {
+ return errors.Is(err, context.DeadlineExceeded)
}
diff --git a/core/services/nodes/unloader_ping_test.go b/core/services/nodes/unloader_ping_test.go
deleted file mode 100644
index a9b3a5889469..000000000000
--- a/core/services/nodes/unloader_ping_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package nodes
-
-import (
- "errors"
- "time"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- "github.com/nats-io/nats.go"
-
- "github.com/mudler/LocalAI/core/services/messaging"
-)
-
-// The scheduler's liveness probe asks a worker a question over NATS and treats
-// "no responders" as proof the worker is gone. That is only sound if every
-// worker in the fleet subscribes to the subject asked.
-//
-// It originally asked models.running, which arrived in 4.6. A 4.5 worker is
-// perfectly alive and serving, answers backend.list, and never subscribes to
-// models.running, so the probe condemned it on every scheduling attempt. A
-// model pinned to such a node could then never be placed at all.
-var _ = Describe("Node liveness probe subject", func() {
- var (
- mc *scriptedMessagingClient
- adapter *RemoteUnloaderAdapter
- )
-
- const nodeID = "11111111-2222-3333-4444-555555555555"
-
- BeforeEach(func() {
- mc = newScriptedMessagingClient()
- adapter = NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute)
- })
-
- It("treats a worker that answers backend.list as alive", func() {
- // A worker old enough to predate models.running: it answers the
- // long-standing backend.list subject and nothing else.
- mc.scriptReply(messaging.SubjectNodeBackendList(nodeID), messaging.BackendListReply{})
- mc.scriptNoResponders(messaging.SubjectNodeModelsRunning(nodeID))
-
- Expect(errors.Is(adapter.PingNode(nodeID), nats.ErrNoResponders)).To(BeFalse(),
- "a worker answering backend.list is alive regardless of newer subjects")
- })
-
- It("still reports a worker that answers nothing as absent", func() {
- mc.scriptNoResponders(messaging.SubjectNodeBackendList(nodeID))
- mc.scriptNoResponders(messaging.SubjectNodeModelsRunning(nodeID))
-
- Expect(errors.Is(adapter.PingNode(nodeID), nats.ErrNoResponders)).To(BeTrue())
- })
-})
diff --git a/core/services/nodes/unloader_stale_rows_test.go b/core/services/nodes/unloader_stale_rows_test.go
index 0fff87bafe20..9be312917aa1 100644
--- a/core/services/nodes/unloader_stale_rows_test.go
+++ b/core/services/nodes/unloader_stale_rows_test.go
@@ -8,6 +8,7 @@ import (
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
)
// Replies are handed to the adapter as raw JSON rather than as marshalled
@@ -18,14 +19,16 @@ import (
var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
var (
locator *fakeModelLocator
- mc *fakeMessagingClient
+ workers *scriptedControlWorkers
adapter *RemoteUnloaderAdapter
)
+ const nodeID = "node-1"
+
BeforeEach(func() {
locator = &fakeModelLocator{}
- mc = &fakeMessagingClient{}
- adapter = NewRemoteUnloaderAdapter(locator, mc, 3*time.Minute, 15*time.Minute)
+ workers = newScriptedControlWorkers()
+ adapter = NewRemoteUnloaderAdapter(locator, nil, workers.controlClient(), 3*time.Minute, 15*time.Minute)
})
Describe("DeleteBackend", func() {
@@ -35,11 +38,11 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// addresses will pass probeHealth as soon as an unrelated backend
// binds the recycled port, and the request is then silently served
// by the wrong backend.
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{
"success": true,
"reports_stopped_processes": true,
"stopped_process_keys": ["qwen3-0.6b#0", "qwen3-0.6b#2"]
- }`)
+ }`))
reply, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -56,11 +59,11 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// contain '#' themselves, so the split must be anchored at the last
// separator. Splitting at the first one addresses a row that does
// not exist and leaves the real stale row in place.
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{
"success": true,
"reports_stopped_processes": true,
"stopped_process_keys": ["weird#name#3"]
- }`)
+ }`))
_, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -70,7 +73,7 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
It("removes nothing when a worker that reports stopped processes stopped none", func() {
// Deleting a backend that was never loaded is routine. The reply is
// authoritative here, so "no keys" genuinely means "no rows".
- mc.requestReply = []byte(`{"success": true, "reports_stopped_processes": true}`)
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{"success": true, "reports_stopped_processes": true}`))
_, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -84,7 +87,7 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// not guess at rows to delete. It falls back to the probe-based
// self-heal in SmartRouter.probeHealth, which is exactly the
// pre-change behavior.
- mc.requestReply = []byte(`{"success": true}`)
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{"success": true}`))
reply, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -98,11 +101,11 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// The worker aborts the delete without listing the key it failed to
// kill: that process survived, so its address is still correct and
// dropping the row would force a needless reload of a live replica.
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{
"success": false,
"error": "could not stop running process qwen3-0.6b#0",
"reports_stopped_processes": true
- }`)
+ }`))
reply, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -115,12 +118,12 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// so a failure further along (removing files, re-registering) does
// not make the already-recycled ports any less dangerous. Gating
// removal on overall success would strand exactly those rows.
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{
"success": false,
"error": "failed to delete backend files",
"reports_stopped_processes": true,
"stopped_process_keys": ["qwen3-0.6b#0"]
- }`)
+ }`))
_, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -128,11 +131,11 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
})
It("ignores malformed process keys instead of removing a wrong row", func() {
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendDelete), []byte(`{
"success": true,
"reports_stopped_processes": true,
"stopped_process_keys": ["no-replica-suffix", "qwen3-0.6b#notanumber", "good#1"]
- }`)
+ }`))
_, err := adapter.DeleteBackend("node-1", "llama-cpp")
Expect(err).NotTo(HaveOccurred())
@@ -145,11 +148,11 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
// An upgrade force-stops every process using the binary and starts
// none of them back up, so it recycles ports exactly as delete does
// while leaving the same rows behind.
- mc.requestReply = []byte(`{
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendUpgrade), []byte(`{
"success": true,
"reports_stopped_processes": true,
"stopped_process_keys": ["whisper#0", "whisper#1"]
- }`)
+ }`))
reply, err := adapter.UpgradeBackend("node-1", "whisper", "", "", "", "", 0, "", nil)
Expect(err).NotTo(HaveOccurred())
@@ -162,7 +165,7 @@ var _ = Describe("RemoteUnloaderAdapter stale replica rows", func() {
})
It("does not read an old worker's silence as a completed cleanup", func() {
- mc.requestReply = []byte(`{"success": true}`)
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendUpgrade), []byte(`{"success": true}`))
reply, err := adapter.UpgradeBackend("node-1", "whisper", "", "", "", "", 0, "", nil)
Expect(err).NotTo(HaveOccurred())
diff --git a/core/services/nodes/unloader_test.go b/core/services/nodes/unloader_test.go
index 8e51aca6cd75..57a22a27b7be 100644
--- a/core/services/nodes/unloader_test.go
+++ b/core/services/nodes/unloader_test.go
@@ -4,16 +4,15 @@ import (
"context"
"encoding/json"
"errors"
- "fmt"
"sync"
"time"
- "github.com/nats-io/nats.go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
)
// --- Fakes ---
@@ -22,6 +21,7 @@ import (
type fakeModelLocator struct {
nodes []BackendNode
findErr error
+ getErr error
removedPairs []modelNodePair // records RemoveNodeModel calls
removedReplicas []modelReplicaRef // records RemoveNodeModel calls including the replica index
}
@@ -46,6 +46,21 @@ func (f *fakeModelLocator) FindNodesWithModel(_ context.Context, _ string) ([]Ba
return f.nodes, f.findErr
}
+// Get answers out of the same node list the locator hands out, so a spec that
+// registers an AGENT node gets an agent node back and the carrier split is
+// driven by the fixture rather than by a second thing to keep in step.
+func (f *fakeModelLocator) Get(_ context.Context, nodeID string) (*BackendNode, error) {
+ if f.getErr != nil {
+ return nil, f.getErr
+ }
+ for i := range f.nodes {
+ if f.nodes[i].ID == nodeID {
+ return &f.nodes[i], nil
+ }
+ }
+ return nil, errors.New("no such node")
+}
+
func (f *fakeModelLocator) RemoveNodeModel(_ context.Context, nodeID, modelName string, replicaIndex int) error {
f.removedPairs = append(f.removedPairs, modelNodePair{nodeID, modelName})
f.removedReplicas = append(f.removedReplicas, modelReplicaRef{nodeID, modelName, replicaIndex})
@@ -59,6 +74,10 @@ func (f *fakeModelLocator) RemoveAllNodeModelReplicas(_ context.Context, nodeID,
// fakeMessagingClient implements messaging.MessagingClient, recording Publish
// and Request calls so we can assert on subjects and payloads.
+//
+// Only ONE verb still reaches it: backend.stop to an agent node. Every other
+// control verb travels over the tunnel, so a publish recorded here for a
+// backend node is a bug, and several specs below assert exactly that.
type fakeMessagingClient struct {
mu sync.Mutex
published []publishCall
@@ -120,6 +139,17 @@ func (f *fakeMessagingClient) Request(subject string, data []byte, timeout time.
func (f *fakeMessagingClient) IsConnected() bool { return true }
func (f *fakeMessagingClient) Close() {}
+// publishedSubjects reports what actually reached the bus.
+func (f *fakeMessagingClient) publishedSubjects() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]string, 0, len(f.published))
+ for _, c := range f.published {
+ out = append(out, c.Subject)
+ }
+ return out
+}
+
type fakeSubscription struct{}
func (f *fakeSubscription) Unsubscribe() error { return nil }
@@ -129,16 +159,24 @@ func (f *fakeSubscription) Unsubscribe() error { return nil }
var _ = Describe("RemoteUnloaderAdapter", func() {
var (
locator *fakeModelLocator
- mc *fakeMessagingClient
+ bus *fakeMessagingClient
+ workers *scriptedControlWorkers
adapter *RemoteUnloaderAdapter
)
BeforeEach(func() {
locator = &fakeModelLocator{}
- mc = &fakeMessagingClient{}
- adapter = NewRemoteUnloaderAdapter(locator, mc, 3*time.Minute, 15*time.Minute)
+ bus = &fakeMessagingClient{}
+ workers = newScriptedControlWorkers()
+ adapter = NewRemoteUnloaderAdapter(locator, bus, workers.controlClient(), 3*time.Minute, 15*time.Minute)
})
+ // scriptStop lets a backend node accept the tunnelled backend.stop, which
+ // answers 204 and therefore carries no body.
+ scriptStop := func(nodeID string) {
+ workers.scriptRawReply(controlKey(nodeID, workerctl.PathBackendStop), []byte(`{}`))
+ }
+
// HasRemoteModel carries the distinction that UnloadRemoteModel
// deliberately does not, so ShutdownModel can answer 404 for a model that
// is loaded neither locally nor anywhere in the cluster without making the
@@ -179,20 +217,25 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
// tests/e2e/distributed/node_lifecycle_test.go — keep them in step.
locator.nodes = nil
Expect(adapter.UnloadRemoteModel("my-model")).To(Succeed())
- Expect(mc.published).To(BeEmpty())
+ Expect(workers.callSubjects()).To(BeEmpty())
+ Expect(bus.publishedSubjects()).To(BeEmpty())
})
- It("broadcasts to all nodes with model", func() {
+ It("stops the backend on every node holding the model, over their tunnels", func() {
locator.nodes = []BackendNode{
- {ID: "node-1", Name: "worker-1"},
- {ID: "node-2", Name: "worker-2"},
+ {ID: "node-1", Name: "worker-1", NodeType: NodeTypeBackend},
+ {ID: "node-2", Name: "worker-2", NodeType: NodeTypeBackend},
}
+ scriptStop("node-1")
+ scriptStop("node-2")
+
Expect(adapter.UnloadRemoteModel("llama")).To(Succeed())
- // Should have published a StopBackend for each node.
- Expect(mc.published).To(HaveLen(2))
- Expect(mc.published[0].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-1")))
- Expect(mc.published[1].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-2")))
+ Expect(workers.callSubjects()).To(Equal([]string{
+ controlKey("node-1", workerctl.PathBackendStop),
+ controlKey("node-2", workerctl.PathBackendStop),
+ }))
+ Expect(bus.publishedSubjects()).To(BeEmpty())
// Should have removed the model from each node in the registry.
Expect(locator.removedPairs).To(HaveLen(2))
@@ -202,46 +245,127 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
It("continues when one node fails", func() {
locator.nodes = []BackendNode{
- {ID: "node-fail", Name: "worker-fail"},
- {ID: "node-ok", Name: "worker-ok"},
+ {ID: "node-fail", Name: "worker-fail", NodeType: NodeTypeBackend},
+ {ID: "node-ok", Name: "worker-ok", NodeType: NodeTypeBackend},
}
- // Use a messaging client that fails the first Publish call only.
- failOnce := &failOnceMessagingClient{inner: mc, failOn: 0}
- adapter = NewRemoteUnloaderAdapter(locator, failOnce, 3*time.Minute, 15*time.Minute)
+ workers.scriptUnroutable("node-fail")
+ scriptStop("node-ok")
Expect(adapter.UnloadRemoteModel("llama")).To(HaveOccurred())
- // The second node should still have been processed.
- // The first node's StopBackend errored, so RemoveNodeModel was NOT called for it.
- // The second node's StopBackend succeeded, so RemoveNodeModel WAS called.
+ // The second node should still have been processed. The first
+ // node's stop errored, so its row was NOT dropped: a row deleted
+ // on a route this frontend could not open is a model reclaimed
+ // while it is still loaded.
Expect(locator.removedPairs).To(HaveLen(1))
Expect(locator.removedPairs[0].nodeID).To(Equal("node-ok"))
})
It("propagates forced shutdown to every worker", func() {
- locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1"}}
+ locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1", NodeType: NodeTypeBackend}}
+ scriptStop("node-1")
+
Expect(adapter.UnloadRemoteModelContext(context.Background(), "llama", true)).To(Succeed())
+ workers.mu.Lock()
+ defer workers.mu.Unlock()
+ Expect(workers.calls).To(HaveLen(1))
var payload messaging.BackendStopRequest
- Expect(json.Unmarshal(mc.published[0].Data, &payload)).To(Succeed())
+ Expect(json.Unmarshal(workers.calls[0].Data, &payload)).To(Succeed())
Expect(payload).To(Equal(messaging.BackendStopRequest{Backend: "llama", Force: true}))
})
+
+ // The carrier split has TWO call sites, which is why stopBackend takes
+ // nodeType as a parameter. StopBackend is pinned in both directions
+ // below; this is the other caller, and hardcoding NodeTypeBackend here
+ // used to leave the whole suite green. An agent node holding a
+ // node_models row would then have its stop sent over a tunnel it does
+ // not hold, the call would fail, and the replica row would be left
+ // behind.
+ It("routes each stop by ITS node's type, not by one choice for the unload", func() {
+ locator.nodes = []BackendNode{
+ {ID: "agent-1", Name: "agent", NodeType: NodeTypeAgent},
+ {ID: "backend-1", Name: "gpu-1", NodeType: NodeTypeBackend},
+ }
+ scriptStop("backend-1")
+
+ Expect(adapter.UnloadRemoteModelContext(context.Background(), "llama", false)).To(Succeed())
+
+ // The agent's stop went to the bus and only the agent's did; the
+ // backend's went over the tunnel and only the backend's did.
+ Expect(bus.publishedSubjects()).To(Equal([]string{messaging.SubjectNodeBackendStop("agent-1")}))
+ Expect(workers.callSubjects()).To(Equal([]string{controlKey("backend-1", workerctl.PathBackendStop)}))
+ // Both rows dropped, which is the negative control: a stop put on
+ // the carrier the other kind of worker listens on fails, and a
+ // failed stop keeps its row.
+ Expect(locator.removedPairs).To(HaveLen(2))
+ })
})
- Describe("StopBackend", func() {
- It("with empty backend publishes nil payload", func() {
- Expect(adapter.StopBackend("node-1", "")).To(Succeed())
- Expect(mc.published).To(HaveLen(1))
- Expect(mc.published[0].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-1")))
- Expect(mc.published[0].Data).To(BeNil())
+ // The carrier split. It is the one verb of the ten that is decided by the
+ // KIND of worker, and getting it wrong is silent in both directions: a
+ // backend node's stop published on the bus reaches nothing, and an agent
+ // node's stop sent over a tunnel it does not hold reaches nothing either.
+ Describe("StopBackend and node type", func() {
+ It("sends a backend stop to a BACKEND node over the tunnel and not over the bus", func() {
+ locator.nodes = []BackendNode{{ID: "backend-1", Name: "gpu-1", NodeType: NodeTypeBackend}}
+ scriptStop("backend-1")
+
+ Expect(adapter.StopBackend("backend-1", "llama-backend")).To(Succeed())
+
+ Expect(workers.callSubjects()).To(ContainElement(controlKey("backend-1", workerctl.PathBackendStop)))
+ Expect(bus.publishedSubjects()).To(BeEmpty())
+ })
+
+ It("sends a backend stop to an AGENT node over the bus, because agent workers hold no tunnel", func() {
+ locator.nodes = []BackendNode{{ID: "agent-1", Name: "agent", NodeType: NodeTypeAgent}}
+
+ Expect(adapter.StopBackend("agent-1", "llama-backend")).To(Succeed())
+
+ Expect(bus.publishedSubjects()).To(ContainElement(messaging.SubjectNodeBackendStop("agent-1")))
+ Expect(workers.callSubjects()).ToNot(ContainElement(controlKey("agent-1", workerctl.PathBackendStop)))
+ })
+
+ It("treats a node whose type cannot be read as a backend worker", func() {
+ // The column defaults to backend and every other node-type branch
+ // in this package reads an empty value the same way. The lookup
+ // failing must not silently move a stop onto a carrier nothing is
+ // listening on.
+ locator.getErr = errors.New("database is down")
+ scriptStop("unknown-1")
+
+ Expect(adapter.StopBackend("unknown-1", "llama-backend")).To(Succeed())
+
+ Expect(workers.callSubjects()).To(ContainElement(controlKey("unknown-1", workerctl.PathBackendStop)))
+ Expect(bus.publishedSubjects()).To(BeEmpty())
+ })
+
+ It("with an empty backend asks the worker to stop everything", func() {
+ locator.nodes = []BackendNode{{ID: "backend-1", Name: "gpu-1", NodeType: NodeTypeBackend}}
+ scriptStop("backend-1")
+
+ Expect(adapter.StopBackend("backend-1", "")).To(Succeed())
+
+ workers.mu.Lock()
+ defer workers.mu.Unlock()
+ var payload messaging.BackendStopRequest
+ Expect(json.Unmarshal(workers.calls[0].Data, &payload)).To(Succeed())
+ // An empty Backend is what the worker reads as "stop everything";
+ // see decodeBackendStopRequest.
+ Expect(payload.Backend).To(BeEmpty())
+ Expect(payload.Force).To(BeFalse())
})
- It("with backend name publishes JSON", func() {
- Expect(adapter.StopBackend("node-1", "llama-backend")).To(Succeed())
- Expect(mc.published).To(HaveLen(1))
+ It("names the backend when one is given", func() {
+ locator.nodes = []BackendNode{{ID: "backend-1", Name: "gpu-1", NodeType: NodeTypeBackend}}
+ scriptStop("backend-1")
+
+ Expect(adapter.StopBackend("backend-1", "llama-backend")).To(Succeed())
+ workers.mu.Lock()
+ defer workers.mu.Unlock()
var payload messaging.BackendStopRequest
- Expect(json.Unmarshal(mc.published[0].Data, &payload)).To(Succeed())
+ Expect(json.Unmarshal(workers.calls[0].Data, &payload)).To(Succeed())
Expect(payload.Backend).To(Equal("llama-backend"))
Expect(payload.Force).To(BeFalse())
})
@@ -249,30 +373,46 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
Describe("StopModelReplica", func() {
It("requests an acknowledged stop for the exact process", func() {
- mc.requestReply, _ = json.Marshal(messaging.ModelStopReply{Matched: true, Terminated: true, ProcessKey: "llama#2"})
- replica := NodeModel{ModelName: "llama", ReplicaIndex: 2, Address: "127.0.0.1:5002", ConfigRevision: "rev-1"}
+ workers.scriptReply(controlKey("node-1", workerctl.PathModelStop),
+ messaging.ModelStopReply{Matched: true, Terminated: true, ProcessKey: "llama#2"})
+ replica := NodeModel{ModelName: "llama", ReplicaIndex: 2, WorkerLocalAddress: "127.0.0.1:5002", ConfigRevision: "rev-1"}
reply, err := adapter.StopModelReplica(context.Background(), "node-1", replica, true)
Expect(err).NotTo(HaveOccurred())
Expect(reply.Terminated).To(BeTrue())
- Expect(mc.requestCalls).To(HaveLen(1))
- Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeModelStop("node-1")))
- Expect(mc.requestCalls[0].Timeout).To(BeNumerically(">", 0))
+
+ workers.mu.Lock()
+ defer workers.mu.Unlock()
+ Expect(workers.calls).To(HaveLen(1))
+ Expect(workers.calls[0].Subject).To(Equal(controlKey("node-1", workerctl.PathModelStop)))
var request messaging.ModelStopRequest
- Expect(json.Unmarshal(mc.requestCalls[0].Data, &request)).To(Succeed())
+ Expect(json.Unmarshal(workers.calls[0].Data, &request)).To(Succeed())
Expect(request).To(Equal(messaging.ModelStopRequest{
ModelName: "llama", ProcessKey: "llama#2", ExpectedAddress: "127.0.0.1:5002", Force: true, ConfigRevision: "rev-1",
}))
})
+
+ It("reports an unroutable worker without inventing a stop reply", func() {
+ // A zero ModelStopReply reads as Matched=false, which the cleanup
+ // path treats as "there was nothing to stop" and drops the row. It
+ // must only ever be paired with an error.
+ workers.scriptUnroutable("node-gone")
+
+ reply, err := adapter.StopModelReplica(context.Background(), "node-gone", NodeModel{ModelName: "llama"}, false)
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ Expect(reply).To(Equal(messaging.ModelStopReply{}))
+ })
})
Describe("StopNode", func() {
- It("publishes to correct subject", func() {
+ It("asks the worker to shut down over its tunnel", func() {
+ workers.scriptRawReply(controlKey("node-abc", workerctl.PathNodeStop), []byte(`{}`))
+
Expect(adapter.StopNode("node-abc")).To(Succeed())
- Expect(mc.published).To(HaveLen(1))
- Expect(mc.published[0].Subject).To(Equal(messaging.SubjectNodeStop("node-abc")))
- Expect(mc.published[0].Data).To(BeNil())
+
+ Expect(workers.callSubjects()).To(Equal([]string{controlKey("node-abc", workerctl.PathNodeStop)}))
+ Expect(bus.publishedSubjects()).To(BeEmpty())
})
})
@@ -284,94 +424,87 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
It("continues on failure", func() {
locator.nodes = []BackendNode{
- {ID: "node-1", Name: "w1"},
- {ID: "node-2", Name: "w2"},
+ {ID: "node-1", Name: "w1", NodeType: NodeTypeBackend},
+ {ID: "node-2", Name: "w2", NodeType: NodeTypeBackend},
}
- // Request will fail for all calls.
- mc.requestErr = fmt.Errorf("timeout")
+ // Neither node is scripted, so both answer the loud
+ // unscripted-verb default. Both must still be attempted.
Expect(adapter.DeleteModelFiles("my-model")).To(Succeed())
- // Both nodes attempted.
- Expect(mc.requestCalls).To(HaveLen(2))
- Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeModelDelete("node-1")))
- Expect(mc.requestCalls[1].Subject).To(Equal(messaging.SubjectNodeModelDelete("node-2")))
+ Expect(workers.callSubjects()).To(Equal([]string{
+ controlKey("node-1", workerctl.PathModelDelete),
+ controlKey("node-2", workerctl.PathModelDelete),
+ }))
})
})
-})
-
-// failOnceMessagingClient wraps fakeMessagingClient but fails the Publish call
-// at index failOn (0-based) and succeeds all others.
-type failOnceMessagingClient struct {
- inner *fakeMessagingClient
- failOn int
- callIdx int
- mu sync.Mutex
-}
-
-func (f *failOnceMessagingClient) Publish(subject string, data any) error {
- f.mu.Lock()
- idx := f.callIdx
- f.callIdx++
- f.mu.Unlock()
- if idx == f.failOn {
- return fmt.Errorf("simulated failure")
- }
- return f.inner.Publish(subject, data)
-}
-func (f *failOnceMessagingClient) Subscribe(subject string, handler func([]byte)) (messaging.Subscription, error) {
- return f.inner.Subscribe(subject, handler)
-}
-
-func (f *failOnceMessagingClient) QueueSubscribe(subject, queue string, handler func([]byte)) (messaging.Subscription, error) {
- return f.inner.QueueSubscribe(subject, queue, handler)
-}
-
-func (f *failOnceMessagingClient) QueueSubscribeReply(subject, queue string, handler func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
- return f.inner.QueueSubscribeReply(subject, queue, handler)
-}
-
-func (f *failOnceMessagingClient) SubscribeReply(subject string, handler func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
- return f.inner.SubscribeReply(subject, handler)
-}
+ Describe("UnloadModelOnNode", func() {
+ It("succeeds when the worker reports the model freed", func() {
+ workers.scriptReply(controlKey("node-1", workerctl.PathModelUnload), messaging.ModelUnloadReply{Success: true})
+ Expect(adapter.UnloadModelOnNode("node-1", "llama")).To(Succeed())
+ })
-func (f *failOnceMessagingClient) Request(subject string, data []byte, timeout time.Duration) ([]byte, error) {
- return f.inner.Request(subject, data, timeout)
-}
+ It("surfaces the worker's own refusal, which is an answer and not a lost route", func() {
+ workers.scriptReply(controlKey("node-1", workerctl.PathModelUnload),
+ messaging.ModelUnloadReply{Success: false, Error: "Free failed"})
-func (f *failOnceMessagingClient) IsConnected() bool { return true }
-func (f *failOnceMessagingClient) Close() {}
+ err := adapter.UnloadModelOnNode("node-1", "llama")
+ Expect(err).To(MatchError(ContainSubstring("Free failed")))
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeFalse())
+ })
+ })
+})
var _ = Describe("RemoteUnloaderAdapter timeout configuration", func() {
- It("passes the configured install timeout to the messaging client", func() {
- mc := newScriptedMessagingClient()
- mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
- adapter := NewRemoteUnloaderAdapter(nil, mc, 7*time.Minute, 11*time.Minute)
+ // Each verb must be given ITS OWN configured budget, and the budget is
+ // observed the only way it can be: by how long the client waits on a worker
+ // that took the request and never answered. HTTP carries no deadline, and
+ // the transport dials on a context of its own, so nothing on the far side
+ // can see it.
+ //
+ // The two budgets are far apart so a swap cannot pass: install would wait
+ // the upgrade budget and upgrade would wait the install one, and each
+ // assertion below is on the wrong side of the divide for the other.
+ const (
+ installBudget = 150 * time.Millisecond
+ upgradeBudget = 600 * time.Millisecond
+ divide = 400 * time.Millisecond
+ )
+
+ It("gives backend.install the configured install timeout", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptHang(controlKey("n1", workerctl.PathBackendInstall))
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), installBudget, upgradeBudget)
+ started := time.Now()
_, err := adapter.InstallBackend("n1", "llama-cpp", "", "[]", "", "", "", 0, "", nil)
- Expect(err).ToNot(HaveOccurred())
+ elapsed := time.Since(started)
- Expect(mc.calls).To(HaveLen(1))
- Expect(mc.calls[0].Timeout).To(Equal(7 * time.Minute))
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue(),
+ "a spent budget is reported as still-installing, got %v", err)
+ Expect(elapsed).To(BeNumerically(">=", installBudget))
+ Expect(elapsed).To(BeNumerically("<", divide))
})
- It("passes the configured upgrade timeout to the messaging client", func() {
- mc := newScriptedMessagingClient()
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade("n1"), messaging.BackendUpgradeReply{Success: true})
- adapter := NewRemoteUnloaderAdapter(nil, mc, 7*time.Minute, 11*time.Minute)
+ It("gives backend.upgrade the configured upgrade timeout", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptHang(controlKey("n1", workerctl.PathBackendUpgrade))
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), installBudget, upgradeBudget)
+ started := time.Now()
_, err := adapter.UpgradeBackend("n1", "llama-cpp", "[]", "", "", "", 0, "", nil)
- Expect(err).ToNot(HaveOccurred())
+ elapsed := time.Since(started)
- Expect(mc.calls).To(HaveLen(1))
- Expect(mc.calls[0].Timeout).To(Equal(11 * time.Minute))
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue(),
+ "a spent budget is reported as still-installing, got %v", err)
+ Expect(elapsed).To(BeNumerically(">", divide))
})
})
-var _ = Describe("RemoteUnloaderAdapter NATS timeout handling", func() {
- It("wraps nats.ErrTimeout from InstallBackend in galleryop.ErrWorkerStillInstalling", func() {
- mc := newScriptedMessagingClient()
- mc.scriptErr(messaging.SubjectNodeBackendInstall("n1"), nats.ErrTimeout)
- adapter := NewRemoteUnloaderAdapter(nil, mc, 100*time.Millisecond, 1*time.Second)
+var _ = Describe("RemoteUnloaderAdapter timeout handling", func() {
+ It("reports a spent budget as still-installing, so the operation shows as running on the worker", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptTimeout("n1")
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 100*time.Millisecond, 1*time.Second)
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).To(HaveOccurred())
@@ -379,57 +512,119 @@ var _ = Describe("RemoteUnloaderAdapter NATS timeout handling", func() {
"expected wrapped ErrWorkerStillInstalling, got %v", err)
})
- It("does NOT wrap non-timeout errors", func() {
- mc := newScriptedMessagingClient()
- mc.scriptErr(messaging.SubjectNodeBackendInstall("n1"), nats.ErrNoResponders)
- adapter := NewRemoteUnloaderAdapter(nil, mc, 100*time.Millisecond, 1*time.Second)
+ It("does NOT report an unroutable worker as still installing", func() {
+ // The two are different facts and the operator UI shows them
+ // differently: one keeps the queue row and pushes the retry out, the
+ // other is a plain failure. Both are non-verdicts, so neither may reap.
+ workers := newScriptedControlWorkers()
+ workers.scriptUnroutable("n1")
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 100*time.Millisecond, 1*time.Second)
+
+ _, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeFalse())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ })
+
+ // The still-installing rule has THREE call sites: InstallBackend,
+ // UpgradeBackend and the legacy force-install fallback. The first two are
+ // pinned by the specs above and by the timeout-configuration pair; the
+ // fallback was the unpinned one, and widening its guard to any error left
+ // the whole suite green. A permanently unroutable worker on the fallback
+ // path would then report as still-installing forever, which galleryop
+ // treats as a soft failure: the retry is pushed out and the operator never
+ // sees the error.
+ It("reports a spent budget on the legacy fallback as still-installing, on the upgrade budget", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptHang(controlKey("n1", workerctl.PathBackendInstall))
+ // Install and upgrade budgets far apart: the fallback re-fires an
+ // INSTALL but is part of an upgrade, so it must wait the upgrade
+ // budget. Waiting the install one would satisfy a bare
+ // still-installing assertion while carrying the wrong deadline.
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 100*time.Millisecond, 500*time.Millisecond)
+
+ started := time.Now()
+ _, err := adapter.installWithForceFallback("n1", "llama-cpp", "[]", "", "", "", 0, "", nil)
+ elapsed := time.Since(started)
+
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue(),
+ "a spent budget is reported as still-installing, got %v", err)
+ Expect(elapsed).To(BeNumerically(">=", 500*time.Millisecond))
+ })
+
+ It("does NOT report an unroutable legacy fallback as still installing", func() {
+ workers := newScriptedControlWorkers()
+ // The verb is not scripted, so the worker fails to SERVE it rather
+ // than answering; that is a 5xx and lands under the no-route umbrella.
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), time.Minute, time.Minute)
+
+ _, err := adapter.installWithForceFallback("n1", "llama-cpp", "[]", "", "", "", 0, "", nil)
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeFalse())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ })
+
+ It("does not read an error merely containing the words nats timeout as a timeout", func() {
+ // The string match the bus carrier needed would have matched any
+ // message quoting the phrase, and would have turned a permanent failure
+ // into "still installing" forever: galleryop reads that as a soft
+ // failure, pushes the retry out, and the operator never sees the error.
+ //
+ // The input has to be an ERROR carrying the phrase. A successful reply
+ // whose Error field carries it comes back with a nil error and never
+ // reaches the classifier at all, so a spec built on one stays green
+ // with the string match restored, which is exactly what the previous
+ // version of this spec did.
+ workers := newScriptedControlWorkers()
+ workers.scriptServerError(controlKey("n1", workerctl.PathBackendInstall),
+ `the worker said "nats: timeout" in its log`)
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), time.Minute, time.Minute)
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("nats: timeout"),
+ "precondition: the phrase must actually reach the timeout classifier")
Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeFalse())
- Expect(errors.Is(err, nats.ErrNoResponders)).To(BeTrue())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
})
})
var _ = Describe("RemoteUnloaderAdapter install progress streaming", func() {
- It("forwards BackendInstallProgressEvent values into the onProgress callback when the worker publishes them", func() {
- mc := newScriptedMessagingClient()
- mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
- mc.scheduleProgressPublish("n1", "op-abc", []messaging.BackendInstallProgressEvent{
+ It("forwards the worker's progress lines to onProgress, in the order it wrote them", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptReply(controlKey("n1", workerctl.PathBackendInstall),
+ messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:0"})
+ workers.scriptProgress(controlKey("n1", workerctl.PathBackendInstall), []messaging.BackendInstallProgressEvent{
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "100 MB", Total: "1 GB", Percentage: 10},
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "500 MB", Total: "1 GB", Percentage: 50},
})
- adapter := NewRemoteUnloaderAdapter(nil, mc, 1*time.Second, 1*time.Second)
- var (
- received []messaging.BackendInstallProgressEvent
- mu sync.Mutex
- )
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), time.Second, time.Second)
+ var received []messaging.BackendInstallProgressEvent
onProgress := func(ev messaging.BackendInstallProgressEvent) {
- mu.Lock()
- defer mu.Unlock()
+ // No lock, and that is an assertion in itself: the callback runs
+ // synchronously on the caller's own goroutine, so a race detector
+ // run would fail here if it did not.
received = append(received, ev)
}
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "op-abc", onProgress)
Expect(err).ToNot(HaveOccurred())
- Eventually(func() int {
- mu.Lock()
- defer mu.Unlock()
- return len(received)
- }, "1s").Should(Equal(2))
+ Expect(received).To(HaveLen(2))
+ Expect([]float64{received[0].Percentage, received[1].Percentage}).To(Equal([]float64{10, 50}))
})
- It("does NOT subscribe when onProgress is nil (reconciler retry path)", func() {
- mc := newScriptedMessagingClient()
- mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true})
+ It("completes when the caller wants no progress at all (reconciler retry path)", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptReply(controlKey("n1", workerctl.PathBackendInstall), messaging.BackendInstallReply{Success: true})
+ workers.scriptProgress(controlKey("n1", workerctl.PathBackendInstall), []messaging.BackendInstallProgressEvent{
+ {OpID: "", NodeID: "n1", Percentage: 42},
+ })
- adapter := NewRemoteUnloaderAdapter(nil, mc, 1*time.Second, 1*time.Second)
- _, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), time.Second, time.Second)
+ reply, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
-
- Expect(mc.subscribeCalls()).To(BeEmpty(),
- "reconciler-driven retries must not subscribe to the per-op progress subject")
+ Expect(reply.Success).To(BeTrue())
})
})
diff --git a/core/services/nodes/unloader_upgrade_test.go b/core/services/nodes/unloader_upgrade_test.go
index bad8f9ed5062..41fe7316f16e 100644
--- a/core/services/nodes/unloader_upgrade_test.go
+++ b/core/services/nodes/unloader_upgrade_test.go
@@ -1,82 +1,78 @@
package nodes
import (
- "sync"
+ "errors"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
)
var _ = Describe("RemoteUnloaderAdapter.UpgradeBackend", func() {
- It("fires a NATS request to the backend.upgrade subject and returns the reply", func() {
- mc := newScriptedMessagingClient()
+ It("calls the backend.upgrade verb on the worker and returns the reply", func() {
+ workers := newScriptedControlWorkers()
nodeID := "node-x"
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(nodeID),
+ workers.scriptReply(controlKey(nodeID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
- adapter := NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute)
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 3*time.Minute, 15*time.Minute)
reply, err := adapter.UpgradeBackend(nodeID, "llama-cpp", `[{"name":"x"}]`, "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(reply.Success).To(BeTrue())
+ Expect(workers.callSubjects()).To(Equal([]string{controlKey(nodeID, workerctl.PathBackendUpgrade)}))
})
- It("returns the underlying error when the subject has no responders", func() {
- mc := newScriptedMessagingClient() // unscripted subject => fakeNoRespondersErr by harness convention
+ It("reports a worker it cannot reach as unroutable rather than as a failed upgrade", func() {
+ workers := newScriptedControlWorkers()
+ workers.scriptUnroutable("missing-node")
- adapter := NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute)
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 3*time.Minute, 15*time.Minute)
_, err := adapter.UpgradeBackend("missing-node", "llama-cpp", "", "", "", "", 0, "", nil)
- Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, ErrWorkerUnroutable)).To(BeTrue())
+ // Not the worker's 404 either: nothing was asked of it, so it did not
+ // say it lacks the verb, and the legacy force-install fallback must not
+ // fire on this.
+ Expect(errors.Is(err, ErrWorkerControlUnsupported)).To(BeFalse())
})
// Reproducer for "upgrade reports progress:0 the whole time" (Bug B). The
// install path streamed per-node download ticks; the upgrade path did a bare
- // request→single-reply with no progress subscription, so a long force-reinstall
- // blocked opaque. The adapter must subscribe to the per-op progress subject
- // (reused from install) BEFORE the request and deliver each tick to onProgress.
+ // request→single-reply with no progress at all, so a long force-reinstall
+ // blocked opaque. Both verbs now stream on the same envelope shape.
It("streams per-node progress ticks during the upgrade", func() {
- mc := newScriptedMessagingClient()
+ workers := newScriptedControlWorkers()
nodeID := "node-slow"
opID := "op-upgrade-1"
- mc.scriptReply(messaging.SubjectNodeBackendUpgrade(nodeID),
+ workers.scriptReply(controlKey(nodeID, workerctl.PathBackendUpgrade),
messaging.BackendUpgradeReply{Success: true})
- // The worker would publish these while force-reinstalling. The harness
- // replays them as soon as the adapter subscribes to the per-op subject.
- mc.scheduleProgressPublish(nodeID, opID, []messaging.BackendInstallProgressEvent{
+ workers.scriptProgress(controlKey(nodeID, workerctl.PathBackendUpgrade), []messaging.BackendInstallProgressEvent{
{NodeID: nodeID, FileName: "llama-cpp.tar", Current: "10 MB", Total: "100 MB", Percentage: 10},
{NodeID: nodeID, FileName: "llama-cpp.tar", Current: "100 MB", Total: "100 MB", Percentage: 100},
})
- var mu sync.Mutex
var got []messaging.BackendInstallProgressEvent
onProgress := func(ev messaging.BackendInstallProgressEvent) {
- mu.Lock()
got = append(got, ev)
- mu.Unlock()
}
- adapter := NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute)
+ adapter := NewRemoteUnloaderAdapter(nil, nil, workers.controlClient(), 3*time.Minute, 15*time.Minute)
reply, err := adapter.UpgradeBackend(nodeID, "llama-cpp", `[{"name":"x"}]`, "", "", "", 0, opID, onProgress)
Expect(err).ToNot(HaveOccurred())
Expect(reply.Success).To(BeTrue())
- // Confirm it subscribed to the (reused) install-progress subject for this op.
- Expect(mc.subscribeCalls()).To(ContainElement(messaging.SubjectNodeBackendInstallProgress(nodeID, opID)))
-
- // Progress events are delivered asynchronously (goroutine-per-event), so
- // poll for both and assert on the set — ordering is best-effort by design.
- Eventually(func() []float64 {
- mu.Lock()
- defer mu.Unlock()
- pcts := make([]float64, 0, len(got))
- for _, e := range got {
- pcts = append(pcts, e.Percentage)
- }
- return pcts
- }, 2*time.Second, 20*time.Millisecond).Should(ConsistOf(float64(10), float64(100)))
+ // Every tick, in the worker's order, and all of them BEFORE the reply
+ // the call returned: the reply line is the last thing on the body, so a
+ // tick arriving late is structurally impossible rather than merely
+ // unlikely.
+ pcts := make([]float64, 0, len(got))
+ for _, e := range got {
+ pcts = append(pcts, e.Percentage)
+ }
+ Expect(pcts).To(Equal([]float64{10, 100}))
})
})
diff --git a/core/services/nodes/worker_readiness.go b/core/services/nodes/worker_readiness.go
index 89530abc827c..9cfddd709974 100644
--- a/core/services/nodes/worker_readiness.go
+++ b/core/services/nodes/worker_readiness.go
@@ -8,7 +8,7 @@ import (
// WorkerReadiness is the gate behind a worker's /readyz probe.
//
// It exists because the worker's HTTP file-transfer server is started before
-// the worker has connected to NATS, and must keep serving after NATS drops.
+// the worker's tunnel is up, and must keep serving after that tunnel drops.
// The probe is therefore installed after the fact rather than passed as a
// value, and must be safe to read from HTTP handler goroutines while the
// startup goroutine is still installing it.
@@ -39,36 +39,43 @@ func (r *WorkerReadiness) Check() error {
return (*fn)()
}
-// natsConn is the slice of *messaging.Client the readiness probe needs. Kept
-// as a local interface so this package does not import messaging (which would
-// be an import cycle) and so tests can supply a fake.
-type natsConn interface {
- IsConnected() bool
+// tunnelConn is the slice of *worker.Tunnel the readiness probe needs. Kept as
+// a local interface so this package does not import the worker package (which
+// would be an import cycle) and so tests can supply a fake.
+type tunnelConn interface {
+ Connected() bool
}
-// ErrNATSDisconnected is reported by NATSReadiness when the worker has lost its
-// NATS connection.
-var ErrNATSDisconnected = errors.New("NATS connection is down: worker cannot receive work")
+// ErrTunnelDisconnected is reported by TunnelReadiness when the worker holds no
+// tunnel session.
+var ErrTunnelDisconnected = errors.New("worker tunnel is down: the frontend cannot reach this worker")
-// NATSReadiness builds the worker's readiness probe.
+// TunnelReadiness builds the worker's readiness probe.
//
// A worker's real health is not "a port is open" — that is precisely the
// failure mode of issue #10987, where a process that serves nothing still
-// answered 200. All of a worker's actual work (backend install/start/stop
-// events, inference dispatch, file-staging notifications) arrives over NATS, so
-// a worker with a dead NATS link is up and useless. Registration is already
-// implied by the probe being reachable at all: the file-transfer server is only
-// started after the worker has successfully registered with the frontend.
+// answered 200. All of a worker's actual work (backend install/start/stop,
+// model lifecycle, file staging, and every inference stream) arrives over its
+// tunnel to the frontend, so a worker with no tunnel session is up and
+// unreachable. It binds only loopback and advertises no address, so there is no
+// second way in. Registration is already implied by the probe being reachable
+// at all: the file-transfer server is only started after the worker has
+// successfully registered with the frontend.
//
-// This is deliberately something the controller cannot already see. The node
-// registry's status/last_heartbeat is fed by an HTTP heartbeat to the frontend,
-// a completely different network path — a worker can keep heartbeating happily
-// while its NATS connection is dead, and look healthy in the registry. The
-// local probe closes that gap.
-func NATSReadiness(conn natsConn) func() error {
+// This is deliberately something the LOCAL supervisor cannot already see. The
+// node registry's status/last_heartbeat is fed by an HTTP heartbeat to the
+// frontend, a completely different network path, so a worker can keep
+// heartbeating happily while its tunnel is dead and look healthy in the
+// registry. The local probe closes that gap.
+//
+// It is a readiness answer and nothing more. The frontend decides whether a
+// worker is GONE from the tunnel session it holds, aged against
+// LOCALAI_WORKER_RECONNECT_GRACE; a 503 here is one container's own report that
+// it cannot serve right now.
+func TunnelReadiness(conn tunnelConn) func() error {
return func() error {
- if conn == nil || !conn.IsConnected() {
- return ErrNATSDisconnected
+ if conn == nil || !conn.Connected() {
+ return ErrTunnelDisconnected
}
return nil
}
diff --git a/core/services/nodes/worker_readiness_test.go b/core/services/nodes/worker_readiness_test.go
index 6771291ced47..7b6f6fb8b28d 100644
--- a/core/services/nodes/worker_readiness_test.go
+++ b/core/services/nodes/worker_readiness_test.go
@@ -10,11 +10,11 @@ import (
. "github.com/onsi/gomega"
)
-// fakeConn stands in for *messaging.Client, which cannot be constructed without
-// a live NATS server. Only IsConnected() is consulted by the readiness probe.
-type fakeConn struct{ connected bool }
+// fakeTunnel stands in for *worker.Tunnel, which this package cannot import
+// (worker imports nodes). Only Connected() is consulted by the readiness probe.
+type fakeTunnel struct{ connected bool }
-func (f *fakeConn) IsConnected() bool { return f.connected }
+func (f fakeTunnel) Connected() bool { return f.connected }
var _ = Describe("WorkerReadiness", func() {
Describe("the gate itself", func() {
@@ -39,16 +39,24 @@ var _ = Describe("WorkerReadiness", func() {
})
})
- Describe("NATSReadiness", func() {
- It("reports ready while the NATS connection is up", func() {
- Expect(NATSReadiness(&fakeConn{connected: true})()).To(Succeed())
+ Describe("TunnelReadiness", func() {
+ It("reports ready once the tunnel holds a session", func() {
+ Expect(TunnelReadiness(fakeTunnel{connected: true})()).To(Succeed())
})
- It("reports not-ready once the NATS connection drops", func() {
+ It("reports not ready while the tunnel holds no session", func() {
// This is the failure mode issue #10987 is about: the process is
- // up and the port is bound, but the worker can receive no work.
- err := NATSReadiness(&fakeConn{connected: false})()
- Expect(err).To(MatchError(ContainSubstring("NATS")))
+ // up and the port is bound, but nothing can reach this worker,
+ // because every request the frontend makes of it arrives over the
+ // tunnel.
+ Expect(TunnelReadiness(fakeTunnel{connected: false})()).To(MatchError(ErrTunnelDisconnected))
+ })
+
+ It("reports not ready for a nil tunnel rather than panicking", func() {
+ // Run installs the probe after StartTunnel, so a nil here means a
+ // wiring mistake. Reporting it beats taking the HTTP handler
+ // goroutine down with it.
+ Expect(TunnelReadiness(nil)()).To(MatchError(ErrTunnelDisconnected))
})
})
@@ -86,15 +94,15 @@ var _ = Describe("WorkerReadiness", func() {
})
It("serves /readyz 503 once the probe reports not-ready", func() {
- ready.Set(func() error { return errors.New("NATS disconnected") })
+ ready.Set(func() error { return errors.New("tunnel disconnected") })
Expect(get("/readyz")).To(Equal(http.StatusServiceUnavailable))
})
It("keeps /healthz at 200 even when readiness fails", func() {
// Liveness is deliberately independent of readiness: a worker whose
- // NATS link is briefly down must not be killed and restarted, or a
- // NATS outage turns into a restart storm across every worker.
- ready.Set(func() error { return errors.New("NATS disconnected") })
+ // tunnel is briefly down must not be killed and restarted, or one
+ // frontend restart turns into a restart storm across every worker.
+ ready.Set(func() error { return errors.New("tunnel disconnected") })
Expect(get("/healthz")).To(Equal(http.StatusOK))
})
})
diff --git a/core/services/nodes/wrapper_transport_test.go b/core/services/nodes/wrapper_transport_test.go
new file mode 100644
index 000000000000..ff504929a60c
--- /dev/null
+++ b/core/services/nodes/wrapper_transport_test.go
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: MIT
+
+package nodes
+
+import (
+ "context"
+ "errors"
+ "net"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
+)
+
+// The reviewer's spec, plus the production shape it was pointing at.
+//
+// The guard added at the fourth reap site asks the client whether the TRANSPORT
+// failed. In production that client is not the one the factory built: SmartRouter
+// hands out result.Client, which is an *InFlightTrackingClient, over a
+// *FileStagingClient whenever a stager is configured. Both embed grpc.Backend,
+// which does not declare LastDialError, so a type assertion on the outermost
+// type read nil and the guard was inert for exactly the models the router
+// produces. Every spec that constructed a raw client by hand passed anyway.
+//
+// This is the third time in this task that a correct fix was disarmed by a
+// layer further out, which is why the mechanism is now one walker rather than a
+// per-caller assertion.
+var _ = Describe("the transport answer through the wrappers the router builds", func() {
+ var (
+ cause error
+ raw grpc.Backend
+ )
+
+ BeforeEach(func() {
+ cause = errors.New("cluster: no route from this replica to that worker")
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(context.Context, string) (net.Conn, error) { return nil, cause }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ raw, err = f.NewClientForNode("X", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Provoke one dial so there is something to report.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ _, _ = raw.HealthCheck(ctx)
+ })
+
+ It("the raw factory client reports, as designed", func() {
+ Expect(unroutable(raw)).To(MatchError(ErrWorkerUnroutable))
+ })
+
+ It("reports through the in-flight tracker, which is what RouteResult.Client is", func() {
+ tracked := NewInFlightTrackingClient(raw, &fakeModelRouter{}, "X", "m", 0)
+ Expect(unroutable(tracked)).To(MatchError(ErrWorkerUnroutable))
+ })
+
+ It("reports through the file staging client, which buildClientForAddr adds", func() {
+ staged := NewFileStagingClient(raw, nil, "X")
+ Expect(unroutable(staged)).To(MatchError(ErrWorkerUnroutable))
+ })
+
+ It("reports through BOTH, nested the way production nests them", func() {
+ // buildClientForAddr wraps in staging, newRouteResult wraps that in
+ // tracking, model_router puts the result on the cached model, and
+ // pkg/model's checkIsLoaded asks it. Two layers, and a walker that
+ // stopped at one would still be wrong here.
+ nested := NewInFlightTrackingClient(NewFileStagingClient(raw, nil, "X"), &fakeModelRouter{}, "X", "m", 0)
+ Expect(unroutable(nested)).To(MatchError(ErrWorkerUnroutable))
+ })
+
+ It("still reports nothing through the wrappers when the dial succeeded", func() {
+ // The other direction, so forwarding cannot pass by always answering
+ // "unroutable": a backend that genuinely died must still be reapable
+ // through the same wrappers.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = listener.Close() })
+
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return func(ctx context.Context, _ string) (net.Conn, error) {
+ return d.DialContext(ctx, "tcp", listener.Addr().String())
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ live, err := f.NewClientForNode("X", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+ _, _ = live.HealthCheck(context.Background())
+
+ nested := NewInFlightTrackingClient(NewFileStagingClient(live, nil, "X"), &fakeModelRouter{}, "X", "m", 0)
+ Expect(unroutable(nested)).To(BeNil())
+ })
+
+ It("keeps the cluster condition matchable through the wrappers", func() {
+ // Not merely "something failed". The five conditions have to survive
+ // the decorators as well as gRPC, or the consumers are guessing again.
+ routed := errors.New("x")
+ f, err := NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(context.Context, string) (net.Conn, error) { return nil, routed }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ c, err := f.NewClientForNode("X", "10.0.0.1:9001", false)
+ Expect(err).ToNot(HaveOccurred())
+ routed = cluster.ErrNoRoute
+ _, _ = c.HealthCheck(context.Background())
+
+ nested := NewInFlightTrackingClient(NewFileStagingClient(c, nil, "X"), &fakeModelRouter{}, "X", "m", 0)
+ got := unroutable(nested)
+ Expect(got).To(MatchError(cluster.ErrNoRoute))
+ Expect(got).ToNot(MatchError(cluster.ErrNoConnection))
+ })
+})
diff --git a/core/services/testutil/testdb.go b/core/services/testutil/testdb.go
index 80e511201b7d..4f36d52c3d31 100644
--- a/core/services/testutil/testdb.go
+++ b/core/services/testutil/testdb.go
@@ -2,7 +2,11 @@ package testutil
import (
"context"
+ "fmt"
+ "net/url"
"runtime"
+ "sync"
+ "sync/atomic"
"time"
"github.com/testcontainers/testcontainers-go"
@@ -16,27 +20,235 @@ import (
. "github.com/onsi/gomega"
)
-// SetupTestDB creates a fresh PostgreSQL 16 container and returns a gorm.DB.
-// The container is cleaned up via DeferCleanup when the test completes.
+// One PostgreSQL container per test PROCESS, not per spec, with a database per
+// SetupTestDB call.
+//
+// Starting a container per spec was both slow and flaky. Slow because a
+// postgres:16 start is seconds and the packages behind this helper hold several
+// hundred specs; flaky because every start was a fresh chance to miss the
+// readiness deadline, and a miss lands in the caller's BeforeEach as a failure
+// of whichever spec happened to be running. That is the exact shape of the
+// intermittent single-spec failure seen twice in this package and never
+// reproduced: one spec of many, no pattern, never twice in the same place.
+// Starting the container once per process leaves one chance to miss it instead
+// of one per spec, and moves that chance onto a deadline that only has to be met
+// while nothing else is competing for the machine.
+//
+// Isolation is unchanged and is what callers actually depend on: each call still
+// hands back an empty database that no other spec can see. The database is
+// dropped when the spec that asked for it ends. Advisory locks, sequences and
+// extensions are all per-database in PostgreSQL, so nothing the packages behind
+// this helper rely on leaks between specs.
+//
+// This mirrors the pattern already proven in tests/e2e/distributed
+// (testhelpers_test.go), which is where the argument and the measurements come
+// from.
+//
+// One container per process rather than one shared across `ginkgo -p` workers is
+// deliberate: parallel Ginkgo processes are separate OS processes, each gets its
+// own container, and nothing has to coordinate database names across them.
+var (
+ sharedOnce sync.Once
+ sharedPG *tcpostgres.PostgresContainer
+ sharedDSN string
+ sharedErr error
+
+ // dbCounter makes each database name unique within this process. The
+ // container is not shared across processes, so a process-local counter is
+ // enough.
+ dbCounter atomic.Int64
+)
+
+// The container outlives every spec, so its teardown belongs to the suite. This
+// registers one AfterSuite in every suite that imports this package, which is
+// every suite that could have started a container; it is a no-op in the ones
+// that never call SetupTestDB.
+//
+// Package-level rather than something callers have to remember: a helper whose
+// cleanup depends on 56 test files each declaring a hook is a helper that leaks
+// containers the first time someone forgets. Registration happens during package
+// initialisation, which is before RunSpecs, so Ginkgo is still building its tree.
+var _ = AfterSuite(func() {
+ if sharedPG == nil {
+ return
+ }
+ // Best-effort: a failed terminate must not fail a suite whose specs all
+ // passed. Testcontainers' reaper removes it in that case.
+ _ = sharedPG.Terminate(context.Background())
+})
+
+// sharedPostgres returns the DSN of this process's PostgreSQL container,
+// starting it on first use.
+//
+// The error is remembered rather than only asserted inside the sync.Once: an
+// assertion there fails the one spec that happened to be first, and every later
+// spec would then find a nil container and fail for some unrelated-looking
+// reason. Re-asserting the stored error makes every affected spec say the same
+// true thing.
+func sharedPostgres() string {
+ GinkgoHelper()
+
+ sharedOnce.Do(func() {
+ ctx := context.Background()
+ sharedPG, sharedErr = tcpostgres.Run(ctx, "postgres:16",
+ tcpostgres.WithDatabase("testdb"),
+ tcpostgres.WithUsername("test"),
+ tcpostgres.WithPassword("test"),
+ // The deadline is per process now, not per spec, so it is generous
+ // on purpose: it is paid once, and the cost of missing it is a
+ // whole suite rather than one spec.
+ testcontainers.WithWaitStrategyAndDeadline(120*time.Second,
+ wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
+ )
+ if sharedErr != nil {
+ return
+ }
+ sharedDSN, sharedErr = sharedPG.ConnectionString(ctx, "sslmode=disable")
+ })
+
+ Expect(sharedErr).ToNot(HaveOccurred(), "the suite's PostgreSQL container could not be started")
+ return sharedDSN
+}
+
+// SetupTestDB returns a gorm.DB on a PostgreSQL database created for the calling
+// spec. The database is dropped, and its connection pool closed, when the spec
+// ends.
func SetupTestDB() *gorm.DB {
+ GinkgoHelper()
if runtime.GOOS == "darwin" {
Skip("testcontainers requires Docker, not available on macOS CI")
}
- ctx := context.Background()
- pgC, err := tcpostgres.Run(ctx, "postgres:16",
- tcpostgres.WithDatabase("testdb"),
- tcpostgres.WithUsername("test"),
- tcpostgres.WithPassword("test"),
- testcontainers.WithWaitStrategyAndDeadline(60*time.Second,
- wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
- )
- Expect(err).ToNot(HaveOccurred())
- DeferCleanup(func() { pgC.Terminate(context.Background()) })
- connStr, err := pgC.ConnectionString(ctx, "sslmode=disable")
- Expect(err).ToNot(HaveOccurred())
- db, err := gorm.Open(postgres.Open(connStr), &gorm.Config{
+
+ dsn := sharedPostgres()
+ name := fmt.Sprintf("testdb_%d", dbCounter.Add(1))
+
+ // Scoped so a failed CREATE cannot leak the pool: the assertion panics out
+ // of this function, and a leaked pool per failing spec exhausts the
+ // server's connection limit for every spec after it.
+ //
+ // CREATE and DROP DATABASE cannot run against the target database itself,
+ // so both go through a short-lived connection to the container's own
+ // maintenance database.
+ func() {
+ admin := openPool(dsn)
+ defer closePool(admin)
+ Expect(admin.Exec(fmt.Sprintf("CREATE DATABASE %q", name)).Error).To(Succeed())
+ }()
+
+ db, err := gorm.Open(postgres.Open(replaceDBName(dsn, name)), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
Expect(err).ToNot(HaveOccurred())
+
+ DeferCleanup(func() {
+ // The caller's own DeferCleanups were registered later and so run
+ // first, which is what lets a spec keep using this handle in its
+ // teardown.
+ closePool(db)
+
+ drop, err := openTolerantPool(dsn)
+ if err != nil {
+ // Reported, never asserted. A cleanup that fails the spec turns one
+ // database hiccup into a failure that buries whatever the spec was
+ // actually about.
+ AddReportEntry("drop test database skipped", fmt.Sprintf("%s: %v", name, err))
+ return
+ }
+ defer closePool(drop)
+ // FORCE terminates whatever connections the spec left open, including
+ // any a background goroutine is still holding (PostgreSQL 13+).
+ if err := drop.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %q WITH (FORCE)", name)).Error; err != nil {
+ AddReportEntry("drop test database failed", fmt.Sprintf("%s: %v", name, err))
+ }
+ })
+
return db
}
+
+// maintenanceDSN is dsn with every server-side timeout disabled as a CONNECTION
+// STARTUP OPTION rather than as a statement.
+//
+// The timeouts have to go because CREATE DATABASE and DROP DATABASE must not be
+// bounded by anything a spec configured. A spec that sets a short
+// statement_timeout on ITS own database cannot reach this connection, but a spec
+// that names the maintenance database by mistake can, and that is not
+// hypothetical: two advisory-lock specs did exactly that.
+//
+// Clearing it with `SET statement_timeout = 0` on an already-open connection is
+// circular and was a real defect here: that connection has already inherited the
+// database's bound, so the statement that clears the bound runs under it and can
+// be aborted by it with SQLSTATE 57014. It failed roughly once in fifty at
+// 8-way concurrency, which is the same invisible load-dependent single-spec
+// flake this helper exists to remove. A startup option removes the circularity
+// instead of buying headroom against it: the value is delivered in the startup
+// packet, so the connection is already unbounded before it can run anything.
+//
+// The route is verified in the driver rather than assumed. pgx puts every URL
+// query parameter into settings (pgconn/config.go:614), `options` is absent from
+// notRuntimeParams (pgconn/config.go:340-362) so it becomes a runtime parameter
+// (pgconn/config.go:374-378), and runtime parameters are copied into the startup
+// message (pgconn/pgconn.go:382-388). PostgreSQL treats `options` as backend
+// command-line switches, so `-c statement_timeout=0` is applied before the
+// session accepts a query.
+func maintenanceDSN(dsn string) (string, error) {
+ u, err := url.Parse(dsn)
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ // Percent-encoded by Encode, and pgx decodes query values before they reach
+ // settings, so the server receives the switches with their spaces intact.
+ q.Set("options", "-c statement_timeout=0 -c lock_timeout=0")
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+// openPool connects to the maintenance database with logging off and no
+// server-side timeouts. Used for the short-lived maintenance connections only;
+// the database a spec is handed keeps gorm's silent logger and the server's
+// defaults, because setting timeouts on it is a thing specs do on purpose.
+func openPool(dsn string) *gorm.DB {
+ GinkgoHelper()
+ db, err := openTolerantPool(dsn)
+ Expect(err).ToNot(HaveOccurred())
+ return db
+}
+
+// openTolerantPool is openPool for the cleanup path, which must report a
+// failure rather than assert one: an assertion here would fail a spec that had
+// already passed, and bury whatever the next real failure was.
+//
+// It carries the same startup options, and the DROP is the statement that most
+// needs them: FORCE waits on terminating other sessions, measured at up to 169ms
+// against the 300ms bound that used to leak here, and a DROP aborted mid-way is
+// swallowed and leaks a database.
+func openTolerantPool(dsn string) (*gorm.DB, error) {
+ maintenance, err := maintenanceDSN(dsn)
+ if err != nil {
+ return nil, err
+ }
+ db, err := gorm.Open(postgres.Open(maintenance), &gorm.Config{Logger: logger.Discard})
+ if err != nil {
+ return nil, err
+ }
+ return db, nil
+}
+
+func closePool(db *gorm.DB) {
+ if db == nil {
+ return
+ }
+ if sqlDB, err := db.DB(); err == nil {
+ _ = sqlDB.Close()
+ }
+}
+
+// replaceDBName swaps the database component of a DSN, preserving credentials,
+// host, port and query parameters.
+func replaceDBName(dsn, name string) string {
+ GinkgoHelper()
+ u, err := url.Parse(dsn)
+ Expect(err).ToNot(HaveOccurred())
+ u.Path = "/" + name
+ return u.String()
+}
diff --git a/core/services/testutil/testdb_internal_test.go b/core/services/testutil/testdb_internal_test.go
new file mode 100644
index 000000000000..b3003c0d0016
--- /dev/null
+++ b/core/services/testutil/testdb_internal_test.go
@@ -0,0 +1,110 @@
+package testutil
+
+import (
+ "fmt"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+)
+
+// These are white-box on purpose: the property is about the connection this
+// package makes for itself, which no caller can reach.
+var _ = Describe("the maintenance connection", func() {
+ It("cannot be bounded by a timeout set on the maintenance database", func() {
+ // The leak this pins is not hypothetical. Two advisory-lock specs named
+ // a database by literal, and once the helper started handing out
+ // per-spec databases those ALTERs landed on the maintenance database
+ // instead, so every CREATE DATABASE and every DROP ... WITH (FORCE) ran
+ // under a 300ms bound. A CREATE that trips it fails another spec's
+ // setup; a DROP that trips it is swallowed and leaks a database. Both
+ // are load-dependent single-spec failures, which is the exact shape
+ // this helper was rewritten to remove.
+ dsn := sharedPostgres()
+
+ var maintenance string
+ func() {
+ probe := openPool(dsn)
+ defer closePool(probe)
+ Expect(probe.Raw("SELECT current_database()").Scan(&maintenance).Error).To(Succeed())
+ }()
+ Expect(maintenance).ToNot(BeEmpty())
+
+ // Impose the leak, then assert a fresh maintenance connection is
+ // unaffected. Reset first so a failure below cannot leave the bound in
+ // place for the rest of the suite.
+ DeferCleanup(func() {
+ reset := openPool(dsn)
+ defer closePool(reset)
+ Expect(reset.Exec(fmt.Sprintf("ALTER DATABASE %q RESET statement_timeout", maintenance)).Error).To(Succeed())
+ Expect(reset.Exec(fmt.Sprintf("ALTER DATABASE %q RESET lock_timeout", maintenance)).Error).To(Succeed())
+ })
+ func() {
+ impose := openPool(dsn)
+ defer closePool(impose)
+ Expect(impose.Exec(fmt.Sprintf("ALTER DATABASE %q SET statement_timeout = '1ms'", maintenance)).Error).To(Succeed())
+ Expect(impose.Exec(fmt.Sprintf("ALTER DATABASE %q SET lock_timeout = '1ms'", maintenance)).Error).To(Succeed())
+ }()
+
+ // The bound is delivered before the first statement, so the check
+ // below is also the connection's first statement. That ordering is the
+ // point: clearing the bound with a SET would be circular, because the
+ // clearing statement inherits the bound it is clearing and can be
+ // aborted by it with 57014. There is no such bootstrap statement now.
+ fresh := openPool(dsn)
+ defer closePool(fresh)
+ var statementTimeout, lockTimeout string
+ Expect(fresh.Raw("SHOW statement_timeout").Scan(&statementTimeout).Error).To(Succeed())
+ Expect(fresh.Raw("SHOW lock_timeout").Scan(&lockTimeout).Error).To(Succeed())
+ Expect(statementTimeout).To(Equal("0"),
+ "a statement_timeout on the maintenance database reached the helper's own connection, so CREATE and DROP DATABASE are bounded by whatever a spec configured")
+ Expect(lockTimeout).To(Equal("0"),
+ "a lock_timeout on the maintenance database reached the helper's own connection")
+
+ // A control, and the reason this spec is not a race. Clearing the bound
+ // with a statement is circular: the clearing statement runs on a
+ // connection that has already inherited the bound. Whether that
+ // particular statement exceeds 1ms is a matter of load, which makes the
+ // defect an intermittent one; whether the FIRST statement on a plain
+ // connection is bounded at all is not. So the control asks the
+ // deterministic question, with a first statement that certainly exceeds
+ // the bound.
+ func() {
+ plain, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
+ Expect(err).ToNot(HaveOccurred())
+ defer closePool(plain)
+ err = plain.Exec("SELECT pg_sleep(0.05)").Error
+ Expect(err).To(HaveOccurred(),
+ "the imposed bound does not reach a fresh connection's first statement, so this spec's subject is not actually under test")
+ Expect(err.Error()).To(ContainSubstring("57014"),
+ "expected the imposed statement_timeout to abort this, got something else")
+ }()
+
+ // The same first statement on a maintenance connection is unbounded.
+ Expect(fresh.Exec("SELECT pg_sleep(0.05)").Error).To(Succeed())
+
+ // And this is the assertion that says WHY, which is the part a
+ // statement-based clearing cannot satisfy. reset_val is the value the
+ // session would fall back to, that is, the value that was in force when
+ // the connection started, before it could run anything. Clearing the
+ // bound with `SET statement_timeout = 0` leaves reset_val at the
+ // database's 1ms: the session is unbounded only because a statement
+ // said so, and that statement ran under the 1ms bound and can be
+ // aborted by it. Delivering it as a startup option makes the connection
+ // unbounded with no statement in between, which is the difference
+ // between a fix and a wider margin.
+ var resetVal string
+ Expect(fresh.Raw(
+ "SELECT reset_val FROM pg_settings WHERE name = 'statement_timeout'",
+ ).Scan(&resetVal).Error).To(Succeed())
+ Expect(resetVal).To(Equal("0"),
+ "the maintenance connection started under a %s bound and cleared it with a statement, so the clearing statement itself runs under the bound it is clearing", resetVal)
+
+ // And the operation the bound would abort still works while it is in
+ // force. 1ms is far below the 14-26ms a CREATE DATABASE takes here, so
+ // this cannot pass by being fast.
+ Expect(SetupTestDB()).ToNot(BeNil())
+ })
+})
diff --git a/core/services/testutil/testdb_test.go b/core/services/testutil/testdb_test.go
new file mode 100644
index 000000000000..d9dd173eb20f
--- /dev/null
+++ b/core/services/testutil/testdb_test.go
@@ -0,0 +1,52 @@
+package testutil_test
+
+import (
+ "testing"
+
+ "github.com/mudler/LocalAI/core/services/testutil"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestTestutil(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Test Utilities Suite")
+}
+
+// The container is shared per process now, so the isolation callers depend on
+// comes from a database per call rather than from a server per call. That is
+// the property 69 call sites across eleven packages assume without saying so,
+// and nothing else in the tree asserts it.
+var _ = Describe("SetupTestDB", func() {
+ type row struct {
+ ID int
+ }
+
+ It("hands back a database no other caller can see into", func() {
+ first := testutil.SetupTestDB()
+ second := testutil.SetupTestDB()
+
+ Expect(first.Exec(`CREATE TABLE isolation_probe (id int)`).Error).To(Succeed())
+ Expect(first.Exec(`INSERT INTO isolation_probe VALUES (1)`).Error).To(Succeed())
+
+ var found []row
+ err := second.Raw(`SELECT id FROM isolation_probe`).Scan(&found).Error
+ Expect(err).To(HaveOccurred(),
+ "two SetupTestDB calls landed on the same database, so every spec can now see every other spec's rows")
+
+ // The second database must also be usable, not merely different: an
+ // isolation check that passed because the second handle was broken
+ // would prove nothing.
+ Expect(second.Exec(`CREATE TABLE isolation_probe (id int)`).Error).To(Succeed())
+ })
+
+ It("hands back an empty database", func() {
+ db := testutil.SetupTestDB()
+ var tables int64
+ Expect(db.Raw(
+ `SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'`,
+ ).Scan(&tables).Error).To(Succeed())
+ Expect(tables).To(BeZero(), "a spec was handed a database another spec had already migrated")
+ })
+})
diff --git a/core/services/worker/addr_test.go b/core/services/worker/addr_test.go
index 4f5b1ba67f4f..10bdfc35bd13 100644
--- a/core/services/worker/addr_test.go
+++ b/core/services/worker/addr_test.go
@@ -1,14 +1,19 @@
package worker
import (
- "os"
- "strings"
+ "net"
+ "strconv"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Worker address resolution", func() {
+ // advertiseAddr and advertiseHTTPAddr used to be specced here. They are
+ // gone with the addresses they resolved: a worker advertises nothing. What
+ // they pinned that still matters is below: the port arithmetic they shared
+ // with the two functions that survived, and the fact that neither of those
+ // resolves to anything but this host.
Describe("effectiveBasePort", func() {
DescribeTable("returns the correct port",
func(addr, serve string, want int) {
@@ -25,61 +30,123 @@ var _ = Describe("Worker address resolution", func() {
)
})
- Describe("advertiseAddr", func() {
- It("returns AdvertiseAddr when set", func() {
- cfg := &Config{
- AdvertiseAddr: "public.example.com:50051",
- Addr: "10.0.0.5:60000",
- }
- Expect(cfg.advertiseAddr()).To(Equal("public.example.com:50051"))
- })
-
- It("returns Addr when set", func() {
- cfg := &Config{Addr: "worker1.example.com:60000"}
- Expect(cfg.advertiseAddr()).To(Equal("worker1.example.com:60000"))
- })
-
- It("falls back to hostname:basePort", func() {
- cfg := &Config{ServeAddr: "0.0.0.0:50051"}
- got := cfg.advertiseAddr()
- _, port, _ := strings.Cut(got, ":")
- Expect(port).To(Equal("50051"))
-
- hostname, _ := os.Hostname()
- if hostname != "" {
- host, _, _ := strings.Cut(got, ":")
- Expect(host).To(Equal(hostname))
- }
- })
- })
-
Describe("resolveHTTPAddr", func() {
DescribeTable("returns the correct address",
func(httpAddr, addr, serve, want string) {
cfg := &Config{HTTPAddr: httpAddr, Addr: addr, ServeAddr: serve}
Expect(cfg.resolveHTTPAddr()).To(Equal(want))
},
+ // An explicit HTTPAddr is bound exactly as written, wildcard
+ // included: an operator who asks for a routable bind gets one, and
+ // the tunnel still reaches it because the http tag ignores the
+ // target and dials whatever this returned.
Entry("HTTPAddr takes priority", "0.0.0.0:8080", "", "", "0.0.0.0:8080"),
- Entry("derives from Addr port minus 1", "", "worker1:60000", "0.0.0.0:50051", "0.0.0.0:59999"),
- Entry("derives from ServeAddr port minus 1", "", "", "0.0.0.0:50051", "0.0.0.0:50050"),
- Entry("default when nothing set", "", "", "", "0.0.0.0:50050"),
+ Entry("derives from Addr port minus 1", "", "worker1:60000", "0.0.0.0:50051", "127.0.0.1:59999"),
+ Entry("derives from ServeAddr port minus 1", "", "", "0.0.0.0:50051", "127.0.0.1:50050"),
+ Entry("default when nothing set", "", "", "", "127.0.0.1:50050"),
)
+
+ It("takes only the port from Addr, never its host", func() {
+ // The host half of Addr names an interface nothing binds any more.
+ // A default bind that carried it forward would put the
+ // file-transfer server back on a routable address.
+ cfg := &Config{Addr: "0.0.0.0:60000"}
+ Expect(cfg.resolveHTTPAddr()).To(Equal("127.0.0.1:59999"))
+ })
})
- Describe("advertiseHTTPAddr", func() {
- DescribeTable("returns the correct address",
- func(advertiseHTTP, advertise, addr, serve, want string) {
- cfg := &Config{
- AdvertiseHTTPAddr: advertiseHTTP,
- AdvertiseAddr: advertise,
- Addr: addr,
- ServeAddr: serve,
- }
- Expect(cfg.advertiseHTTPAddr()).To(Equal(want))
- },
- Entry("AdvertiseHTTPAddr takes priority", "public.example.com:8080", "", "", "", "public.example.com:8080"),
- Entry("derives from advertiseAddr host + basePort-1", "", "", "worker1.example.com:60000", "", "worker1.example.com:59999"),
- Entry("uses AdvertiseAddr host with basePort-1", "", "public.example.com:60000", "10.0.0.5:60000", "", "public.example.com:59999"),
- )
+ Describe("backendListenAddr", func() {
+ It("binds a backend process on the host the tunnel dials", func() {
+ // Not a literal on either side: this asserts the bind is built from
+ // the same constant the grpc stream tag dials, which is what makes
+ // "the worker binds where its tunnel dials" true rather than
+ // coincidental.
+ Expect(backendListenAddr(50052)).To(Equal(net.JoinHostPort(loopbackHost, strconv.Itoa(50052))))
+ })
+
+ It("binds no wildcard", func() {
+ // Stated separately from the equality above so a change to
+ // loopbackHost itself cannot make both pass while publishing every
+ // backend process on every interface.
+ host, _, err := net.SplitHostPort(backendListenAddr(50052))
+ Expect(err).ToNot(HaveOccurred())
+ ip := net.ParseIP(host)
+ Expect(ip).ToNot(BeNil(), "the backend bind address must be an IP, not a name that could resolve anywhere")
+ Expect(ip.IsLoopback()).To(BeTrue(), "backend processes must bind loopback only")
+ })
+ })
+
+ Describe("registrationBody", func() {
+ It("advertises no address at all", func() {
+ // The registration body is one of the three places this worker used
+ // to state where it could be reached. A key here is not inert: the
+ // frontend stores it, the API returns it, and the Nodes page shows
+ // it as an endpoint.
+ cfg := &Config{NodeName: "w1", Addr: "0.0.0.0:50051", ModelsPath: GinkgoT().TempDir()}
+ body := cfg.registrationBody()
+ Expect(body).To(HaveKeyWithValue("name", "w1"))
+ Expect(body).ToNot(HaveKey("address"))
+ Expect(body).ToNot(HaveKey("http_address"))
+ })
+ })
+})
+
+var _ = Describe("Worker startup validation", func() {
+ // A Config as kong would hand it over with nothing unusual set: the tunnel
+ // on by its default, the required frontend URL present, no auth
+ // enforcement. Every case below starts here and changes ONE thing, so a
+ // refusal it asserts is the clause it names and not an earlier one.
+ newConfig := func() *Config {
+ return &Config{WorkerTunnel: true, RegisterTo: "http://frontend:8080"}
+ }
+
+ It("accepts the default configuration", func() {
+ Expect(newConfig().validateStartup()).To(Succeed())
+ })
+
+ It("starts a backend worker with no NATS URL", func() {
+ // The point of this phase: a backend worker's work arrives over its
+ // tunnel, so a bus address is no longer part of its startup contract.
+ // newConfig sets none, and there is no longer a field to set.
+ Expect(newConfig().validateStartup()).To(Succeed())
+ })
+
+ It("still refuses a worker with no frontend URL", func() {
+ cfg := newConfig()
+ cfg.RegisterTo = ""
+ Expect(cfg.validateStartup()).To(MatchError(ContainSubstring("LOCALAI_REGISTER_TO")))
+ })
+
+ It("refuses to start with the tunnel turned off", func() {
+ // Not a warning and not a degraded mode. A worker without its tunnel
+ // advertises nothing, binds only loopback, and has no frontend path
+ // that dials it, yet it would register, heartbeat and report healthy,
+ // so the scheduler would keep placing models on it and every one would
+ // fail. Refusing at boot is the only outcome that is visible.
+ cfg := newConfig()
+ cfg.WorkerTunnel = false
+ err := cfg.validateStartup()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("LOCALAI_WORKER_TUNNEL"))
+ Expect(err.Error()).To(ContainSubstring("nothing can reach it"))
+ })
+
+ It("refuses enforcement without a registration token", func() {
+ cfg := newConfig()
+ cfg.RegistrationRequireAuth = true
+ Expect(cfg.validateStartup()).To(MatchError(ContainSubstring("LOCALAI_REGISTRATION_TOKEN is empty")))
+ })
+
+ It("refuses the umbrella switch without a registration token", func() {
+ cfg := newConfig()
+ cfg.DistributedRequireAuth = true
+ Expect(cfg.validateStartup()).To(MatchError(ContainSubstring("LOCALAI_REGISTRATION_TOKEN is empty")))
+ })
+
+ It("accepts enforcement once a token is set", func() {
+ cfg := newConfig()
+ cfg.DistributedRequireAuth = true
+ cfg.RegistrationToken = "shared"
+ Expect(cfg.validateStartup()).To(Succeed())
})
})
diff --git a/core/services/worker/auth_required_test.go b/core/services/worker/auth_required_test.go
index ff1deba5caaa..937a52928821 100644
--- a/core/services/worker/auth_required_test.go
+++ b/core/services/worker/auth_required_test.go
@@ -5,18 +5,10 @@ import (
. "github.com/onsi/gomega"
)
+// The umbrella switch used to imply NATS auth as well. A backend worker no
+// longer authenticates to a bus, so registration auth is all it still implies,
+// and this is the only helper left to keep honest.
var _ = Describe("Worker auth-required helpers", func() {
- DescribeTable("NatsAuthRequired",
- func(nats, umbrella, want bool) {
- cfg := &Config{NatsRequireAuth: nats, DistributedRequireAuth: umbrella}
- Expect(cfg.NatsAuthRequired()).To(Equal(want))
- },
- Entry("neither", false, false, false),
- Entry("granular only", true, false, true),
- Entry("umbrella only", false, true, true),
- Entry("both", true, true, true),
- )
-
DescribeTable("RegistrationAuthRequired",
func(reg, umbrella, want bool) {
cfg := &Config{RegistrationRequireAuth: reg, DistributedRequireAuth: umbrella}
diff --git a/core/services/worker/config.go b/core/services/worker/config.go
index 8057e69fe790..84d06c56611a 100644
--- a/core/services/worker/config.go
+++ b/core/services/worker/config.go
@@ -1,26 +1,29 @@
package worker
+import "fmt"
+
// Config is the configuration for the distributed agent worker.
//
// Field tags are kong/kong-env metadata read by core/cli/worker.go's WorkerCMD,
// which embeds Config; this package does NOT import kong and the tags are inert
// here.
//
-// Workers are backend-agnostic — they wait for backend.install NATS events
-// from the SmartRouter to install and start the required backend.
-//
-// NATS is required. The worker acts as a process supervisor:
-// - Receives backend.install → installs backend from gallery, starts gRPC process, replies success
-// - Receives backend.stop → stops the gRPC process
-// - Receives stop → full shutdown (deregister + exit)
-//
-// Model loading (LoadModel) is always via direct gRPC — no NATS needed for that.
+// Workers are backend-agnostic: they install and start whichever backend the
+// frontend asks for. The worker acts as a process supervisor, and every verb
+// the frontend gives it is an HTTP route on its own loopback server, served to
+// the frontend through this worker's outbound tunnel (see control_routes.go and
+// core/services/workerctl). A backend worker connects to no message bus.
type Config struct {
- // Primary address — the reachable address of this worker.
- // Host is used for advertise, port is the base for gRPC backends.
- // HTTP file transfer runs on port-1.
- Addr string `env:"LOCALAI_ADDR" help:"Address where this worker is reachable (host:port). Port is base for gRPC backends, port-1 for HTTP." group:"server"`
- ServeAddr string `env:"LOCALAI_SERVE_ADDR" default:"0.0.0.0:50051" help:"(Advanced) gRPC base port bind address" group:"server" hidden:""`
+ // Addr and ServeAddr are read for their PORT only. A worker binds nothing
+ // on a routable interface: backend processes and the file-transfer server
+ // both listen on loopback and are reached through this worker's outbound
+ // tunnel. The port still matters because it is the base of the backend
+ // port range (and port-1 is the HTTP server), so an operator who needs a
+ // different range sets it here. The host half is ignored, and is kept
+ // accepted rather than rejected so an upgraded worker starts on the
+ // environment it already had.
+ Addr string `env:"LOCALAI_ADDR" help:"Base port for this worker, as host:port; only the port is used. Backends take ports upward from it, the HTTP file-transfer server takes port-1. Nothing binds a routable interface." group:"server"`
+ ServeAddr string `env:"LOCALAI_SERVE_ADDR" default:"0.0.0.0:50051" help:"(Advanced) gRPC base port; only the port is used" group:"server" hidden:""`
// GRPCMaxPort bounds the dynamic gRPC port allocator at [basePort, this].
// The width of that range is how many backend processes this worker can run
@@ -42,23 +45,39 @@ type Config struct {
// cluster-internal path is slow (slirp/circuit-relay, CGNAT) but outbound NAT
// works fine. Resolution reuses the same gallery installer the master uses, so
// the on-disk /models layout is identical. Errors are non-fatal — if the gallery
- // is unreachable on boot, the worker logs a warning and starts the NATS loop
- // anyway; the master can still push the file on demand (existing behaviour).
+ // is unreachable on boot, the worker logs a warning and starts anyway; the master can still push the file on demand (existing behaviour).
PrefetchModels []string `env:"LOCALAI_PREFETCH_MODELS,PREFETCH_MODELS" help:"Comma-separated gallery model IDs to download from LOCALAI_GALLERIES at worker boot (e.g. 'llama-3.2-1b-instruct,phi-3-mini-4k'). Skipped if already on disk and SHA matches." group:"server"`
- // HTTP file transfer
- HTTPAddr string `env:"LOCALAI_HTTP_ADDR" default:"" help:"HTTP file transfer server address (default: gRPC port + 1)" group:"server" hidden:""`
- AdvertiseHTTPAddr string `env:"LOCALAI_ADVERTISE_HTTP_ADDR" help:"HTTP address the frontend uses to reach this node for file transfer" group:"server" hidden:""`
+ // HTTPAddr binds the HTTP file-transfer server. Default is loopback on
+ // basePort-1; an explicit value is bound exactly as given.
+ HTTPAddr string `env:"LOCALAI_HTTP_ADDR" default:"" help:"HTTP file transfer server bind address (default: loopback on the gRPC base port - 1)" group:"server" hidden:""`
// Registration (required)
- AdvertiseAddr string `env:"LOCALAI_ADVERTISE_ADDR" help:"Address the frontend uses to reach this node (defaults to hostname:port from Addr)" group:"registration" hidden:""`
RegisterTo string `env:"LOCALAI_REGISTER_TO" required:"" help:"Frontend URL for registration" group:"registration"`
NodeName string `env:"LOCALAI_NODE_NAME" help:"Node name for registration (defaults to hostname)" group:"registration"`
RegistrationToken string `env:"LOCALAI_REGISTRATION_TOKEN" help:"Token for authenticating with the frontend" group:"registration"`
RegistrationRequireAuth bool `env:"LOCALAI_REGISTRATION_REQUIRE_AUTH" default:"false" help:"Refuse to start the HTTP file-transfer server when no registration token is set (otherwise it fails open and serves read/write to models/staging/data unauthenticated)" group:"registration"`
- DistributedRequireAuth bool `env:"LOCALAI_DISTRIBUTED_REQUIRE_AUTH" default:"false" help:"Umbrella switch implying both --nats-require-auth and --registration-require-auth" group:"distributed"`
+ DistributedRequireAuth bool `env:"LOCALAI_DISTRIBUTED_REQUIRE_AUTH" default:"false" help:"Umbrella switch implying --registration-require-auth" group:"distributed"`
HeartbeatInterval string `env:"LOCALAI_HEARTBEAT_INTERVAL" default:"10s" help:"Interval between heartbeats" group:"registration"`
- NodeLabels string `env:"LOCALAI_NODE_LABELS" help:"Comma-separated key=value labels for this node (e.g. tier=fast,gpu=a100)" group:"registration"`
+ // WorkerTunnel holds one outbound multiplexed connection to the frontend
+ // and serves the frontend's requests over it, so the worker needs no
+ // inbound port.
+ //
+ // Turning it off is now a fatal misconfiguration and validateStartup
+ // refuses to boot on it, which is a behaviour change from when this flag
+ // had a working "off" position. It no longer has one: this worker
+ // advertises no address and binds only loopback, and no frontend path
+ // dials a worker's address, so a worker without its tunnel is reachable by
+ // nothing. Left running it would be the worst available failure shape,
+ // because it registers, heartbeats and reports healthy, so the scheduler
+ // keeps placing models on it and every one of them fails.
+ //
+ // The flag is kept rather than deleted so that an operator who set it, on
+ // the old promise that it fell back to the advertised address, is told
+ // exactly that the promise is gone instead of having their setting quietly
+ // ignored.
+ WorkerTunnel bool `env:"LOCALAI_WORKER_TUNNEL" default:"true" help:"Hold one outbound multiplexed tunnel to the frontend and serve its requests over it, so this worker needs no inbound port. Setting it false is refused: a worker has no other way to be reached." group:"distributed"`
+ NodeLabels string `env:"LOCALAI_NODE_LABELS" help:"Comma-separated key=value labels for this node (e.g. tier=fast,gpu=a100)" group:"registration"`
// MaxReplicasPerModel caps how many replicas of any one model can run on
// this worker concurrently. Default 1 = historical single-replica
// behavior. Set higher when a node has enough VRAM to host multiple
@@ -73,14 +92,14 @@ type Config struct {
// enforces it against the raw VRAM this worker reports. Empty = no cap.
VRAMBudget string `env:"LOCALAI_VRAM_BUDGET" help:"Cap VRAM used for model allocation on this worker node, as a percentage (e.g. 80%) or absolute amount (e.g. 12GB)." group:"registration"`
- // NATS (required)
- NatsURL string `env:"LOCALAI_NATS_URL" required:"" help:"NATS server URL" group:"distributed"`
- NatsJWT string `env:"LOCALAI_NATS_JWT" help:"NATS user JWT override (normally from registration nats_jwt)" group:"distributed"`
- NatsUserSeed string `env:"LOCALAI_NATS_USER_SEED" help:"NATS user signing seed override (normally from registration nats_user_seed)" group:"distributed"`
- NatsRequireAuth bool `env:"LOCALAI_NATS_REQUIRE_AUTH" default:"false" help:"Require NATS JWT+seed from registration or env" group:"distributed"`
- NatsTLSCA string `env:"LOCALAI_NATS_TLS_CA" type:"existingfile" help:"PEM file for NATS server CA (private PKI)" group:"distributed"`
- NatsTLSCert string `env:"LOCALAI_NATS_TLS_CERT" type:"existingfile" help:"Client certificate for NATS mTLS" group:"distributed"`
- NatsTLSKey string `env:"LOCALAI_NATS_TLS_KEY" type:"existingfile" help:"Client private key for NATS mTLS" group:"distributed"`
+ // NatsURL is accepted and ignored. A backend worker no longer connects to
+ // NATS at all, and the credential and TLS flags that went with it are gone
+ // from this command. This one is kept so that an operator whose worker
+ // command line or unit file still carries --nats-url gets a worker that
+ // starts, rather than a kong parse error on an upgrade whose whole point is
+ // that the bus is no longer needed here. The frontend and agent workers
+ // still take it and still mean it.
+ NatsURL string `env:"LOCALAI_NATS_URL" help:"Ignored. A backend worker connects to no message bus; the frontend reaches it over its outbound tunnel. Accepted so an existing worker command line still starts." group:"distributed" hidden:""`
// S3 storage for distributed file transfer
StorageURL string `env:"LOCALAI_STORAGE_URL" help:"S3 endpoint URL" group:"distributed"`
@@ -90,14 +109,37 @@ type Config struct {
StorageSecretKey string `env:"LOCALAI_STORAGE_SECRET_KEY" help:"S3 secret key" group:"distributed"`
}
-// NatsAuthRequired reports whether NATS JWT credentials must be present — the
-// granular flag or the umbrella (LOCALAI_DISTRIBUTED_REQUIRE_AUTH).
-func (c Config) NatsAuthRequired() bool {
- return c.NatsRequireAuth || c.DistributedRequireAuth
-}
-
// RegistrationAuthRequired reports whether a registration token must be set
// before the file-transfer server may start — the granular flag or the umbrella.
func (c Config) RegistrationAuthRequired() bool {
return c.RegistrationRequireAuth || c.DistributedRequireAuth
}
+
+// validateStartup reports a configuration this worker must refuse to boot on,
+// as opposed to one it can degrade under.
+//
+// It runs before prefetch and registration, so a refusal happens while the
+// worker is still invisible to the cluster. That ordering is the point of
+// checking here at all: these conditions produce a worker that would register,
+// heartbeat and be scheduled onto, so discovering them later means discovering
+// them as failed inferences on a node the frontend believes is healthy.
+func (c Config) validateStartup() error {
+ // kong marks --register-to required, so a worker started from the CLI
+ // cannot miss it. Checked again here because this is the fail-fast site the
+ // other startup refusals live at, and because RegisterTo is now load
+ // bearing twice over: it is where the worker registers AND the endpoint its
+ // tunnel dials, which is the only way anything reaches it.
+ if c.RegisterTo == "" {
+ return fmt.Errorf("no frontend URL: set LOCALAI_REGISTER_TO (or --register-to). It is where this worker registers and the endpoint its tunnel dials, and nothing can reach a worker without it")
+ }
+ // The file-transfer server fails open on an empty token (see
+ // nodes.checkBearerToken), so enforcement plus no token is a request to
+ // serve the models directory unauthenticated.
+ if c.RegistrationAuthRequired() && c.RegistrationToken == "" {
+ return fmt.Errorf("registration auth is required (LOCALAI_REGISTRATION_REQUIRE_AUTH or LOCALAI_DISTRIBUTED_REQUIRE_AUTH) but LOCALAI_REGISTRATION_TOKEN is empty: refusing to start an unauthenticated file-transfer server")
+ }
+ if !c.WorkerTunnel {
+ return fmt.Errorf("LOCALAI_WORKER_TUNNEL is false, but this worker advertises no address and binds only loopback, and no frontend path dials a worker's address: without its tunnel nothing can reach it. Remove the setting, or run the pre-tunnel release on both the worker and the frontend")
+ }
+ return nil
+}
diff --git a/core/services/worker/control_client_roundtrip_test.go b/core/services/worker/control_client_roundtrip_test.go
new file mode 100644
index 000000000000..52046f320f1a
--- /dev/null
+++ b/core/services/worker/control_client_roundtrip_test.go
@@ -0,0 +1,151 @@
+package worker
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The frontend's control client against the REAL worker control plane: the real
+// supervisor's handlers, mounted through the real nodes.AuthenticatedRoutes so
+// the real bearer check runs first, reached by a real nodes.ControlClient over
+// a real HTTP transport. The only thing standing in for production is the
+// transport's dial, which is the seam the tunnel occupies.
+//
+// It lives in this package because the dependency runs worker -> nodes: the
+// frontend's client cannot be exercised against the real handler from the other
+// side without an import cycle. Both halves of the contract are written by
+// different packages, so a spec on either side alone can only prove that side
+// agrees with itself; the paths, the envelope shape and the status codes are
+// only pinned together here.
+var _ = Describe("the frontend's control client against the real worker", func() {
+ const (
+ token = "s3cret-registration-token"
+ nodeID = "worker-under-test"
+ )
+
+ var (
+ sup *backendSupervisor
+ client *nodes.ControlClient
+ srvAddr string
+ sigCh chan os.Signal
+ )
+
+ // newClient builds a control client whose transport dials srvAddr,
+ // authenticating with the token given.
+ newClient := func(srvAddr, tok string) *nodes.ControlClient {
+ return nodes.NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", srvAddr)
+ }
+ }, tok)
+ }
+
+ BeforeEach(func() {
+ sigCh = make(chan os.Signal, 1)
+ sup = &backendSupervisor{
+ cfg: &Config{},
+ nodeID: nodeID,
+ sigCh: sigCh,
+ processes: map[string]*backendProcess{},
+ }
+
+ dir := GinkgoT().TempDir()
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ srv, err := nodes.StartFileTransferServerWithRoutes(lis,
+ filepath.Join(dir, "staging"), filepath.Join(dir, "models"), filepath.Join(dir, "data"),
+ token, config.DefaultMaxUploadSize, nil,
+ &nodes.AuthenticatedRoutes{Prefix: workerctl.Prefix, Register: sup.RegisterControlRoutes})
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = srv.Close() })
+
+ srvAddr = lis.Addr().String()
+ client = newClient(srvAddr, token)
+ })
+
+ It("round-trips models.running end to end", func() {
+ var reply messaging.ModelsRunningReply
+ Expect(client.Call(context.Background(), nodeID, workerctl.PathModelsRunning,
+ messaging.ModelsRunningRequest{}, &reply)).To(Succeed())
+ // Nothing is running, and an empty list is the worker's real answer
+ // rather than a decode that quietly produced nothing: the client
+ // reports a body it cannot read as unroutable instead.
+ Expect(reply.Models).To(BeEmpty())
+ Expect(reply.Error).To(BeEmpty())
+ })
+
+ It("streams install progress in order and returns the terminal reply", func() {
+ sup.installFn = func(_ context.Context, req messaging.BackendInstallRequest, _ bool,
+ onProgress func(messaging.BackendInstallProgressEvent)) (string, error) {
+ onProgress(messaging.BackendInstallProgressEvent{OpID: req.OpID, Percentage: 50})
+ onProgress(messaging.BackendInstallProgressEvent{OpID: req.OpID, Percentage: 100})
+ return "127.0.0.1:41234", nil
+ }
+
+ var seen []float64
+ var reply messaging.BackendInstallReply
+ err := client.CallStreaming(context.Background(), nodeID, workerctl.PathBackendInstall,
+ messaging.BackendInstallRequest{Backend: "mock", OpID: "op-1"}, &reply,
+ func(ev messaging.BackendInstallProgressEvent) { seen = append(seen, ev.Percentage) })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(seen).To(Equal([]float64{50, 100}))
+ Expect(reply.Success).To(BeTrue())
+ Expect(reply.WorkerLocalAddress).To(Equal("127.0.0.1:41234"))
+ })
+
+ It("reports a FAILED install as the worker's own answer, not as a transport failure", func() {
+ // The distinction the two sides exist to preserve: the worker answers
+ // 200 with Error set, so the frontend reads a verdict a caller may act
+ // on rather than a route it must not act on.
+ sup.installFn = func(context.Context, messaging.BackendInstallRequest, bool,
+ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ return "", errors.New("no child with platform linux/arm64")
+ }
+
+ var reply messaging.BackendInstallReply
+ err := client.CallStreaming(context.Background(), nodeID, workerctl.PathBackendInstall,
+ messaging.BackendInstallRequest{Backend: "mock"}, &reply, nil)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(reply.Success).To(BeFalse())
+ Expect(reply.Error).To(ContainSubstring("linux/arm64"))
+ })
+
+ It("round-trips a 204 verb, which carries no body at all", func() {
+ Expect(client.Call(context.Background(), nodeID, workerctl.PathBackendStop,
+ messaging.BackendStopRequest{Backend: "no-such-backend"}, nil)).To(Succeed())
+ })
+
+ It("reports an unknown control verb as unsupported, and not as absence", func() {
+ err := client.Call(context.Background(), nodeID, workerctl.Prefix+"invented", struct{}{}, &struct{}{})
+ Expect(err).To(MatchError(nodes.ErrWorkerControlUnsupported))
+ Expect(errors.Is(err, cluster.ErrNoRoute)).To(BeFalse())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+
+ It("reports a rejected token as unroutable, never as a worker verdict about a backend", func() {
+ // The bearer check runs before routing, so a wrong token is a 401 for
+ // every verb. It says nothing about any backend and nothing may reap on
+ // it; it also must not be read as the verb being unsupported, which
+ // would send the upgrade path into its destructive legacy fallback.
+ wrong := newClient(srvAddr, "not-the-token")
+ var reply messaging.BackendListReply
+ err := wrong.Call(context.Background(), nodeID, workerctl.PathBackendList,
+ messaging.BackendListRequest{}, &reply)
+ Expect(errors.Is(err, nodes.ErrWorkerUnroutable)).To(BeTrue())
+ Expect(errors.Is(err, nodes.ErrWorkerControlUnsupported)).To(BeFalse())
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ })
+})
diff --git a/core/services/worker/control_files.go b/core/services/worker/control_files.go
new file mode 100644
index 000000000000..2b05781a915f
--- /dev/null
+++ b/core/services/worker/control_files.go
@@ -0,0 +1,268 @@
+package worker
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+ "github.com/mudler/xlog"
+)
+
+// The worker's file-staging control plane: four verbs that move model and job
+// artifacts between the object store both sides share and this worker's disk.
+// They replace the four nodes..files.* NATS subjects.
+//
+// The reply shapes are the ones those subjects already carried, unchanged, so
+// an operator reading the wire sees the same fields. What DID change is that a
+// listing is no longer sized against a bus payload: it is a response body the
+// caller is already reading, which is why nothing here truncates one.
+//
+// The 200-with-error-field shape is the same one the lifecycle verbs take, and
+// for the same reason: "that file is not there" is the worker's own ANSWER, and
+// a reap guard may act on it, while a non-2xx is this frontend failing to reach
+// the worker, which nothing may act on. A handler that answered 500 for a
+// failed upload would move its verdict into the bucket reserved for a broken
+// link.
+
+// The file-staging reply bodies. They mirror the frontend's decode structs in
+// core/services/nodes/file_stager_s3.go field for field; the two are written
+// separately because neither package may import the other, and the roundtrip
+// spec is what holds them together.
+type fileEnsureReply struct {
+ LocalPath string `json:"local_path,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type fileStageReply struct {
+ Key string `json:"key,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type fileTempReply struct {
+ LocalPath string `json:"local_path,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type fileListDirReply struct {
+ Files []string `json:"files,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// stagingCacheDir is the one place the worker's staging cache directory is
+// derived from its configuration.
+//
+// One place and not two, because the FileManager caches INTO this directory and
+// the listdir and temp verbs resolve paths AGAINST it. Two derivations that
+// drifted would give a worker that downloads a file to one directory and then
+// reports it missing from another, and both halves would still be self
+// consistent.
+func (cfg *Config) stagingCacheDir() string {
+ return filepath.Join(cfg.ModelsPath, "..", "cache")
+}
+
+// stagingDataDir is where keys under storage.DataKeyPrefix resolve, and it is
+// derived from the cache directory for the same single-source reason.
+func (cfg *Config) stagingDataDir() string {
+ return filepath.Join(cfg.stagingCacheDir(), "..", "data")
+}
+
+// NewStagingFileManager builds the FileManager the file-staging verbs serve
+// from, over the same object store the frontend uses.
+//
+// It returns an error rather than degrading, because a worker whose deployment
+// asked for object storage and could not reach it would otherwise mount four
+// verbs that fail every call, which the frontend cannot tell from a worker out
+// of disk.
+func (cfg *Config) NewStagingFileManager(ctx context.Context) (*storage.FileManager, error) {
+ s3Store, err := storage.NewS3Store(ctx, storage.S3Config{
+ Endpoint: cfg.StorageURL,
+ Region: cfg.StorageRegion,
+ Bucket: cfg.StorageBucket,
+ AccessKeyID: cfg.StorageAccessKey,
+ SecretAccessKey: cfg.StorageSecretKey,
+ ForcePathStyle: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("initializing S3 store: %w", err)
+ }
+ fm, err := storage.NewFileManager(s3Store, cfg.stagingCacheDir())
+ if err != nil {
+ return nil, fmt.Errorf("initializing file manager: %w", err)
+ }
+ return fm, nil
+}
+
+// RegisterFileControlRoutes mounts the four file-staging verbs on mux.
+//
+// The caller is responsible for putting mux behind authentication; see
+// nodes.AuthenticatedRoutes, which is how the worker mounts this so the file
+// verbs share one bearer check with the lifecycle verbs and the file routes
+// rather than growing a third one.
+func (cfg *Config) RegisterFileControlRoutes(mux *http.ServeMux, fm *storage.FileManager) {
+ cacheDir := cfg.stagingCacheDir()
+
+ // files.ensure: download an object-store key into this worker's cache and
+ // say where it landed.
+ //
+ // It takes the caller's context. Nothing is terminated and no resource is
+ // held if it is abandoned half way: the download simply stops, and the next
+ // attempt starts over. So the caller's budget is the operation's budget.
+ postControlVerb(mux, workerctl.PathFilesEnsure, func(ctx context.Context, body []byte) (any, error) {
+ var req struct {
+ Key string `json:"key"`
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid files.ensure request: %w", err)
+ }
+ localPath, err := fm.Download(ctx, req.Key)
+ if err != nil {
+ xlog.Error("File ensure failed", "key", req.Key, "error", err)
+ return fileEnsureReply{Error: err.Error()}, nil
+ }
+ xlog.Debug("File ensured locally", "key", req.Key, "path", localPath)
+ return fileEnsureReply{LocalPath: localPath}, nil
+ })
+
+ // files.stage: upload one of this worker's files to the object store.
+ //
+ // The path allow-list is what keeps this verb from being an exfiltration
+ // primitive: the token holder can name any absolute path, so only the
+ // directories this worker stages out of are served.
+ postControlVerb(mux, workerctl.PathFilesStage, func(ctx context.Context, body []byte) (any, error) {
+ var req struct {
+ LocalPath string `json:"local_path"`
+ Key string `json:"key"`
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid files.stage request: %w", err)
+ }
+ allowedDirs := []string{cacheDir}
+ if cfg.ModelsPath != "" {
+ allowedDirs = append(allowedDirs, cfg.ModelsPath)
+ }
+ if !isPathAllowed(req.LocalPath, allowedDirs) {
+ return fileStageReply{Error: "path outside allowed directories"}, nil
+ }
+ if err := fm.Upload(ctx, req.Key, req.LocalPath); err != nil {
+ xlog.Error("File stage failed", "path", req.LocalPath, "key", req.Key, "error", err)
+ return fileStageReply{Error: err.Error()}, nil
+ }
+ xlog.Debug("File staged to the object store", "path", req.LocalPath, "key", req.Key)
+ return fileStageReply{Key: req.Key}, nil
+ })
+
+ // files.temp: allocate an empty file the frontend may then upload into.
+ postControlVerb(mux, workerctl.PathFilesTemp, func(context.Context, []byte) (any, error) {
+ tmpDir := filepath.Join(cacheDir, "staging-tmp")
+ if err := os.MkdirAll(tmpDir, 0750); err != nil {
+ return fileTempReply{Error: fmt.Sprintf("creating temp dir: %v", err)}, nil
+ }
+ f, err := os.CreateTemp(tmpDir, "localai-staging-*.tmp")
+ if err != nil {
+ return fileTempReply{Error: fmt.Sprintf("creating temp file: %v", err)}, nil
+ }
+ localPath := f.Name()
+ if err := f.Close(); err != nil {
+ return fileTempReply{Error: fmt.Sprintf("closing temp file: %v", err)}, nil
+ }
+ xlog.Debug("Allocated temp file", "path", localPath)
+ return fileTempReply{LocalPath: localPath}, nil
+ })
+
+ // files.listdir: the relative paths of every file under one key prefix.
+ //
+ // Nothing here caps the answer. Over NATS the reply had to fit a payload
+ // the bus was willing to carry, and a wide model directory was the case
+ // that risked it; over HTTP the listing is written into a body the caller
+ // is already reading, so its size is no longer a property of the carrier. A
+ // cap would silently return a SHORT listing, which the frontend reads as
+ // files that are not there.
+ postControlVerb(mux, workerctl.PathFilesListDir, func(ctx context.Context, body []byte) (any, error) {
+ var req struct {
+ KeyPrefix string `json:"key_prefix"`
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid files.listdir request: %w", err)
+ }
+ dirPath, ok := cfg.resolveStagingDir(req.KeyPrefix)
+ if !ok {
+ return fileListDirReply{Error: "invalid key prefix"}, nil
+ }
+ files, err := listStagedFiles(ctx, dirPath)
+ if err != nil {
+ xlog.Error("Failed to list staged files", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "error", err)
+ return fileListDirReply{Error: err.Error()}, nil
+ }
+ xlog.Debug("Listed remote dir", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "fileCount", len(files))
+ return fileListDirReply{Files: files}, nil
+ })
+}
+
+// resolveStagingDir maps a storage key prefix onto the local directory it names,
+// and reports whether that directory is one this worker serves.
+//
+// The second return is not an error string on purpose: a prefix that climbs out
+// of the served directories is refused before anything touches the filesystem,
+// so a crafted key_prefix cannot turn this verb into a directory reader for the
+// whole host.
+func (cfg *Config) resolveStagingDir(keyPrefix string) (string, bool) {
+ cacheDir := cfg.stagingCacheDir()
+ dataDir := cfg.stagingDataDir()
+
+ dirPath := filepath.Join(cacheDir, keyPrefix)
+ if rel, ok := strings.CutPrefix(keyPrefix, storage.ModelKeyPrefix); ok && cfg.ModelsPath != "" {
+ dirPath = filepath.Join(cfg.ModelsPath, rel)
+ } else if rel, ok := strings.CutPrefix(keyPrefix, storage.DataKeyPrefix); ok {
+ dirPath = filepath.Join(dataDir, rel)
+ }
+
+ dirPath = filepath.Clean(dirPath)
+ cleanCache := filepath.Clean(cacheDir)
+ cleanModels := filepath.Clean(cfg.ModelsPath)
+ cleanData := filepath.Clean(dataDir)
+ within := func(root string) bool {
+ return dirPath == root || strings.HasPrefix(dirPath, root+string(filepath.Separator))
+ }
+ if within(cleanCache) || (cleanModels != "." && within(cleanModels)) || within(cleanData) {
+ return dirPath, true
+ }
+ return "", false
+}
+
+// listStagedFiles walks dirPath and returns every file's path relative to it.
+//
+// The walk honours ctx because a very wide directory is real work, and a caller
+// that has already given up should not keep this worker stat-ing files. The
+// context error is returned as the walk's error, so it travels back as a
+// FAILURE of the listing rather than as an empty listing, which the frontend
+// would read as a directory with nothing in it.
+func listStagedFiles(ctx context.Context, dirPath string) ([]string, error) {
+ var files []string
+ err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ if d.IsDir() {
+ return nil
+ }
+ rel, relErr := filepath.Rel(dirPath, path)
+ if relErr != nil {
+ return relErr
+ }
+ files = append(files, rel)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return files, nil
+}
diff --git a/core/services/worker/control_files_test.go b/core/services/worker/control_files_test.go
new file mode 100644
index 000000000000..0668e2de6e7e
--- /dev/null
+++ b/core/services/worker/control_files_test.go
@@ -0,0 +1,407 @@
+package worker
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The four file-staging verbs on the worker's own control plane.
+//
+// They used to be NATS request-reply subjects, and the size of a listdir reply
+// was a property of the carrier: a directory with enough files in it produced a
+// payload the bus was not comfortable with. Over HTTP it is a response body,
+// which is why one of the specs below asserts a listing far past any payload
+// cap rather than asserting a cap of its own.
+var _ = Describe("worker file-staging control routes", func() {
+ var (
+ cfg *Config
+ srv *httptest.Server
+ fm *storage.FileManager
+ store *storage.FilesystemStore
+ modelsDir string
+ cacheDir string
+ )
+
+ // post issues one control verb the way the frontend does and hands back the
+ // raw response, so a spec can assert the STATUS as well as the body. The
+ // two carry different meanings and a helper that decoded only the body
+ // would hide the one this file exists to pin.
+ post := func(path string, body any) *http.Response {
+ GinkgoHelper()
+ raw, err := json.Marshal(body)
+ Expect(err).NotTo(HaveOccurred())
+ resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(raw))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ return resp
+ }
+
+ decode := func(resp *http.Response, out any) {
+ GinkgoHelper()
+ Expect(json.NewDecoder(resp.Body).Decode(out)).To(Succeed())
+ }
+
+ BeforeEach(func() {
+ dir := GinkgoT().TempDir()
+ modelsDir = filepath.Join(dir, "models")
+ Expect(os.MkdirAll(modelsDir, 0o750)).To(Succeed())
+ cacheDir = filepath.Join(dir, "cache")
+
+ var err error
+ store, err = storage.NewFilesystemStore(filepath.Join(dir, "objectstore"))
+ Expect(err).NotTo(HaveOccurred())
+ fm, err = storage.NewFileManager(store, cacheDir)
+ Expect(err).NotTo(HaveOccurred())
+
+ cfg = &Config{ModelsPath: modelsDir}
+ mux := http.NewServeMux()
+ cfg.RegisterFileControlRoutes(mux, fm)
+ srv = httptest.NewServer(mux)
+ DeferCleanup(srv.Close)
+ })
+
+ // The on-disk layout, written out BY HAND rather than derived from the
+ // helpers under test. What these directories ARE is an operator-facing
+ // contract: a deployment mounts a volume per directory and sizes it, so a
+ // join that moved would put staged bytes on a volume nobody provisioned.
+ // Deriving the expectation from the helper would pin nothing.
+ DescribeTable("derives its directories from the models path, once each",
+ func(got, want string) { Expect(got).To(Equal(want)) },
+ Entry("cache", (&Config{ModelsPath: "/srv/localai/models"}).stagingCacheDir(), "/srv/localai/cache"),
+ Entry("data", (&Config{ModelsPath: "/srv/localai/models"}).stagingDataDir(), "/srv/localai/data"),
+ )
+
+ It("allocates a temp path and returns it", func() {
+ resp := post(workerctl.PathFilesTemp, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ LocalPath string `json:"local_path"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(BeEmpty())
+ Expect(reply.LocalPath).To(BeAnExistingFile())
+ })
+
+ It("downloads a key the store already holds and reports where it landed", func() {
+ key := storage.ModelKey("ensure-me.gguf")
+ Expect(store.Put(context.Background(), key, strings.NewReader("weights"))).To(Succeed())
+
+ resp := post(workerctl.PathFilesEnsure, map[string]string{"key": key})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ LocalPath string `json:"local_path"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(BeEmpty())
+ Expect(reply.LocalPath).To(BeAnExistingFile())
+ Expect(os.ReadFile(reply.LocalPath)).To(Equal([]byte("weights")))
+ })
+
+ It("uploads a file under an allowed directory and answers with its key", func() {
+ local := filepath.Join(modelsDir, "staged.bin")
+ Expect(os.WriteFile(local, []byte("output"), 0o600)).To(Succeed())
+
+ resp := post(workerctl.PathFilesStage, map[string]string{"local_path": local, "key": "data/out.bin"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Key string `json:"key"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(BeEmpty())
+ Expect(reply.Key).To(Equal("data/out.bin"))
+ exists, err := store.Exists(context.Background(), "data/out.bin")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(exists).To(BeTrue())
+ })
+
+ It("returns a listing longer than a NATS payload would have carried", func() {
+ // 4000 files at ~40 bytes of name each is ~160 KB, twenty times the
+ // NOTIFY cap and well past what the old carrier was comfortable with.
+ // The point of the spec is that size is no longer a property of the
+ // transport.
+ bigDir := filepath.Join(modelsDir, "big")
+ Expect(os.MkdirAll(bigDir, 0o750)).To(Succeed())
+ for i := range 4000 {
+ name := fmt.Sprintf("shard-%030d.safetensors", i)
+ Expect(os.WriteFile(filepath.Join(bigDir, name), []byte("x"), 0o600)).To(Succeed())
+ }
+
+ resp := post(workerctl.PathFilesListDir, map[string]string{"key_prefix": "models/big"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Files []string `json:"files"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(BeEmpty())
+ Expect(reply.Files).To(HaveLen(4000))
+ })
+
+ // The rule these four pin is ONE rule stated at four handlers: a verb's own
+ // failure is the worker's ANSWER and travels as a 200 with the error field
+ // set, never as a 5xx. A 5xx is what the frontend maps onto "this frontend
+ // could not reach that worker", which nothing may act on, so a handler that
+ // answered 500 would move its own verdict into the bucket reserved for a
+ // broken link. Each handler is pinned separately because each writes the
+ // rule out for itself.
+ It("reports a staging failure as a 200 with an error field, not as a 5xx", func() {
+ resp := post(workerctl.PathFilesStage, map[string]string{"local_path": "/nope", "key": "k"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ })
+
+ It("reports an upload that failed as a 200 with an error field, not as a 5xx", func() {
+ // The path is INSIDE an allowed directory and the file is simply not
+ // there, so this reaches the upload rather than stopping at the
+ // allow-list. The two are separate statements of the same rule inside
+ // one handler, and a spec that only ever reaches the allow-list leaves
+ // the upload free to answer 500 for the worker's own verdict.
+ missing := filepath.Join(modelsDir, "was-never-written.bin")
+ resp := post(workerctl.PathFilesStage, map[string]string{"local_path": missing, "key": "data/x"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Key string `json:"key"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ Expect(reply.Error).NotTo(ContainSubstring("outside allowed directories"))
+ Expect(reply.Key).To(BeEmpty())
+ })
+
+ It("reports an ensure of a key the store does not hold as a 200 with an error field", func() {
+ resp := post(workerctl.PathFilesEnsure, map[string]string{"key": "models/absent.gguf"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ LocalPath string `json:"local_path"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ Expect(reply.LocalPath).To(BeEmpty())
+ })
+
+ It("reports a listdir of a directory that is not there as a 200 with an error field", func() {
+ resp := post(workerctl.PathFilesListDir, map[string]string{"key_prefix": "models/never-created"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Files []string `json:"files"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ Expect(reply.Files).To(BeEmpty())
+ })
+
+ It("reports a temp allocation it cannot make as a 200 with an error field", func() {
+ // A regular file where the staging directory must go: MkdirAll cannot
+ // create through it, for root as much as for anyone else, so the verb
+ // fails for a reason that is entirely this worker's own.
+ Expect(os.MkdirAll(cacheDir, 0o750)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(cacheDir, "staging-tmp"), []byte("not a dir"), 0o600)).To(Succeed())
+
+ resp := post(workerctl.PathFilesTemp, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ LocalPath string `json:"local_path"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ Expect(reply.LocalPath).To(BeEmpty())
+ })
+
+ It("reports a temp file it cannot create as a 200 with an error field", func() {
+ // The staging directory ALREADY EXISTS and is unwritable, so MkdirAll
+ // succeeds and CreateTemp is the branch that fails. It is a second
+ // statement of the same rule inside the same handler as the MkdirAll
+ // spec above, and a spec that only ever reaches MkdirAll leaves this
+ // one free to answer a non-2xx for the worker's own verdict.
+ Expect(os.MkdirAll(filepath.Join(cacheDir, "staging-tmp"), 0o500)).To(Succeed())
+
+ resp := post(workerctl.PathFilesTemp, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ LocalPath string `json:"local_path"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).NotTo(BeEmpty())
+ Expect(reply.LocalPath).To(BeEmpty())
+ })
+
+ It("refuses a key prefix that climbs out of the directories it serves", func() {
+ resp := post(workerctl.PathFilesListDir, map[string]string{"key_prefix": "../../../etc"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Files []string `json:"files"`
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(ContainSubstring("invalid key prefix"))
+ Expect(reply.Files).To(BeEmpty())
+ })
+
+ It("refuses to upload a path outside the directories it serves", func() {
+ outside := filepath.Join(GinkgoT().TempDir(), "secret")
+ Expect(os.WriteFile(outside, []byte("nope"), 0o600)).To(Succeed())
+
+ resp := post(workerctl.PathFilesStage, map[string]string{"local_path": outside, "key": "data/leak"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply struct {
+ Error string `json:"error"`
+ }
+ decode(resp, &reply)
+ Expect(reply.Error).To(ContainSubstring("outside allowed directories"))
+ })
+
+ // The bounded, POST-only entry point, pinned at every one of the four
+ // paths rather than at one of them. A route that skipped it would be a
+ // second, unbounded door onto a boundary this worker serves, and it would
+ // also let a liveness probe or an address bar run a command.
+ DescribeTable("refuses a method that is not POST",
+ func(path string) {
+ resp, err := http.Get(srv.URL + path)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed))
+ },
+ Entry("ensure", workerctl.PathFilesEnsure),
+ Entry("stage", workerctl.PathFilesStage),
+ Entry("temp", workerctl.PathFilesTemp),
+ Entry("listdir", workerctl.PathFilesListDir),
+ )
+
+ DescribeTable("refuses a body past the control cap, and as a rejected request rather than an answer",
+ func(path string) {
+ // VALID JSON past the cap, deliberately. A body of filler is
+ // refused by the decoder whether or not anything bounds it, so a
+ // spec written that way passes for a reason that has nothing to do
+ // with the bound and would keep passing after the bound was
+ // removed. This one can only be refused by the bound.
+ oversized := append([]byte(`{"key":"`), bytes.Repeat([]byte("a"), maxControlRequestBytes+1)...)
+ oversized = append(oversized, []byte(`"}`)...)
+ resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(oversized))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ },
+ Entry("ensure", workerctl.PathFilesEnsure),
+ Entry("stage", workerctl.PathFilesStage),
+ Entry("temp", workerctl.PathFilesTemp),
+ Entry("listdir", workerctl.PathFilesListDir),
+ )
+
+ // The other direction of the same bound, kept beside the refusals so the
+ // pair is local rather than spread across the suite.
+ //
+ // The two entries hold DIFFERENT halves and both are needed. The
+ // cap-relative body holds that the boundary is inclusive, so the bound is a
+ // ceiling and not an off-by-one; it moves with the cap and therefore says
+ // nothing about the cap's size. The absolute body holds that the cap is
+ // large enough for real traffic: BackendInstallRequest.BackendGalleries is
+ // a serialized gallery list of a few hundred kilobytes, so a cap tightened
+ // below a megabyte would start refusing ordinary requests, and only an
+ // entry written in absolute bytes notices that.
+ DescribeTable("serves a body that fits inside the control cap",
+ func(path string, size int) {
+ body := append([]byte(`{"key":"`), bytes.Repeat([]byte("a"), size-len(`{"key":""}`))...)
+ body = append(body, []byte(`"}`)...)
+ Expect(body).To(HaveLen(size))
+
+ resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(body))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ },
+ Entry("ensure, exactly at the cap", workerctl.PathFilesEnsure, maxControlRequestBytes),
+ Entry("stage, exactly at the cap", workerctl.PathFilesStage, maxControlRequestBytes),
+ Entry("temp, exactly at the cap", workerctl.PathFilesTemp, maxControlRequestBytes),
+ Entry("listdir, exactly at the cap", workerctl.PathFilesListDir, maxControlRequestBytes),
+ Entry("ensure, a megabyte of real traffic", workerctl.PathFilesEnsure, 1<<20),
+ Entry("stage, a megabyte of real traffic", workerctl.PathFilesStage, 1<<20),
+ Entry("temp, a megabyte of real traffic", workerctl.PathFilesTemp, 1<<20),
+ Entry("listdir, a megabyte of real traffic", workerctl.PathFilesListDir, 1<<20),
+ )
+
+ // A body this worker could not PARSE is the frontend's request being wrong,
+ // not this worker's verdict about a file. The two live in opposite buckets:
+ // a non-2xx is mapped onto ErrWorkerUnroutable, which nothing may act on,
+ // while a 200 with the error field set passes through unwrapped so
+ // cluster.IsWorkerAnswer sees it and a reap guard MAY act on it. A handler
+ // that answered `{"error":"invalid request"}` for an unparseable body would
+ // hand a malformed request to a reap guard as evidence about a file.
+ //
+ // Pinned at every verb that decodes a body, because each one writes the
+ // exit out for itself. The base's NATS handlers shipped the wrong answer
+ // here; adopting the shared door fixed it, and this is what keeps it fixed.
+ DescribeTable("reports a request body it cannot read as a rejection, never as a file that is not there",
+ func(path string) {
+ resp, err := http.Post(srv.URL+path, "application/json", strings.NewReader("{not json"))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+
+ // The status alone is not the whole rule: a 400 whose body still
+ // decodes as a reply with an error field would be read correctly by
+ // today's client and wrongly by anything that reads the body first.
+ var reply struct {
+ Error string `json:"error"`
+ }
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).NotTo(Succeed())
+ },
+ Entry("ensure", workerctl.PathFilesEnsure),
+ Entry("stage", workerctl.PathFilesStage),
+ Entry("listdir", workerctl.PathFilesListDir),
+ // temp decodes no body, so it has no such exit to state.
+ )
+
+ It("fails a listing the caller abandoned rather than answering a short one", func() {
+ // A caller that gave up must not be answered with the files walked so
+ // far. A partial listing is the one shape the frontend cannot tell from
+ // a directory that really is that size, and it would read as files the
+ // worker does not have. So the walk returns the context error and the
+ // verb reports a FAILED listing.
+ dir := filepath.Join(modelsDir, "abandoned")
+ Expect(os.MkdirAll(dir, 0o750)).To(Succeed())
+ for i := range 32 {
+ Expect(os.WriteFile(filepath.Join(dir, fmt.Sprintf("f-%02d", i)), []byte("x"), 0o600)).To(Succeed())
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ files, err := listStagedFiles(ctx, dir)
+ Expect(err).To(MatchError(context.Canceled))
+ Expect(files).To(BeEmpty())
+ })
+
+ It("mounts every file verb, so none can be dropped from the set the frontend calls", func() {
+ for _, path := range []string{
+ workerctl.PathFilesEnsure, workerctl.PathFilesStage,
+ workerctl.PathFilesTemp, workerctl.PathFilesListDir,
+ } {
+ resp := post(path, struct{}{})
+ Expect(resp.StatusCode).NotTo(Equal(http.StatusNotFound), "%s is not mounted", path)
+ }
+ })
+})
diff --git a/core/services/worker/control_routes.go b/core/services/worker/control_routes.go
new file mode 100644
index 000000000000..56d251cacb44
--- /dev/null
+++ b/core/services/worker/control_routes.go
@@ -0,0 +1,412 @@
+package worker
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "unicode/utf8"
+
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+ "github.com/mudler/xlog"
+)
+
+// The worker's control plane, served as ordinary HTTP on the loopback server
+// the worker already runs, and reached only through the tunnel's `http` stream
+// tag. It replaces ten NATS subscriptions.
+//
+// HTTP and not a new stream tag, and the reason is not convenience. Every one
+// of correlation, per-request deadlines, unbounded payloads and a progress
+// stream is something a new tag would have had to invent, and each is a place
+// this branch has already put a defect. Riding the tag that already exists also
+// adds nothing to the worker's stream-refusal vocabulary, which is the table a
+// frontend decides what to reap on. And a control RPC to a worker another
+// replica holds takes the SAME relay the inference path takes, which is the
+// path that has been measured, rather than a second one that has not.
+//
+// A handler's own failure is a 200 carrying a reply with Error set, NOT a 5xx.
+// The distinction is load-bearing: the frontend maps a transport failure onto
+// "this frontend has no route to that worker", which nothing may reap on, and a
+// worker's answer onto evidence a reap guard MAY act on. A handler that
+// answered 500 for "the install failed" would put the worker's own verdict into
+// the bucket reserved for a broken link. Only a failure to read or route the
+// request is a non-2xx.
+
+// maxControlRequestBytes bounds a control request body.
+//
+// The largest real body is BackendInstallRequest.BackendGalleries, a serialized
+// gallery list of a few hundred kilobytes. Eight megabytes is therefore not a
+// size the protocol needs: it is a defence against a body that never ends,
+// arriving on a boundary this worker now serves.
+const maxControlRequestBytes = 8 << 20
+
+// maxEchoedPathBytes bounds how much of an unknown control path the 404 body
+// repeats back. The path is caller-controlled and the answer exists to be read
+// in a log line, so a caller cannot make this worker echo a request-sized
+// string into one.
+const maxEchoedPathBytes = 128
+
+// installFunc and upgradeFunc are the shapes of the two long-running verbs.
+// They exist as named types so the fields that override them below read as one
+// thing rather than as two inline signatures.
+type installFunc func(ctx context.Context, req messaging.BackendInstallRequest, force bool,
+ onProgress func(messaging.BackendInstallProgressEvent)) (string, error)
+
+type upgradeFunc func(ctx context.Context, req messaging.BackendUpgradeRequest,
+ onProgress func(messaging.BackendInstallProgressEvent)) ([]string, error)
+
+// installer and upgrader return the implementation the streaming verbs call.
+//
+// The override fields exist so the ROUTING can be specced without a gallery, a
+// registry or a real download, which is the same argument tunnelServices was
+// extracted under: the part of this file that can put a worker's verdict in the
+// wrong bucket is the part that has nothing to do with installing anything.
+func (s *backendSupervisor) installer() installFunc {
+ if s.installFn != nil {
+ return s.installFn
+ }
+ return s.installBackend
+}
+
+func (s *backendSupervisor) upgrader() upgradeFunc {
+ if s.upgradeFn != nil {
+ return s.upgradeFn
+ }
+ return s.upgradeBackend
+}
+
+// RegisterControlRoutes mounts every control verb on mux.
+//
+// The caller is responsible for putting mux behind authentication; see
+// nodes.AuthenticatedRoutes, which is how the worker mounts this so the control
+// plane shares one bearer check with the file routes rather than growing a
+// second one.
+func (s *backendSupervisor) RegisterControlRoutes(mux *http.ServeMux) {
+ post := func(path string, h controlVerb) { postControlVerb(mux, path, h) }
+
+ post(workerctl.PathModelsRunning, func(context.Context, []byte) (any, error) {
+ return messaging.ModelsRunningReply{Models: s.runningModels()}, nil
+ })
+
+ // model.stop deliberately does NOT take the caller's context, and the
+ // asymmetry with model.unload below is the point. This is the ACKNOWLEDGED
+ // stop path: it reserves the process, frees it, kills it, waits for it to
+ // exit and releases its port. Abandoning that half way because the caller
+ // stopped listening would leave a process the worker has marked stopping,
+ // a port not returned to the allocator, and a controller row nothing ever
+ // reconciles. The stop has to finish whether or not anyone reads the
+ // answer; its own bounds are internal and already in place.
+ post(workerctl.PathModelStop, func(_ context.Context, body []byte) (any, error) {
+ var req messaging.ModelStopRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid model.stop request: %w", err)
+ }
+ return s.stopModelExact(req), nil
+ })
+
+ post(workerctl.PathBackendList, func(context.Context, []byte) (any, error) {
+ return s.backendList(), nil
+ })
+
+ post(workerctl.PathBackendDelete, func(_ context.Context, body []byte) (any, error) {
+ var req messaging.BackendDeleteRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid backend.delete request: %w", err)
+ }
+ return s.deleteBackend(req), nil
+ })
+
+ // model.unload is the one verb that DOES take the caller's context. Free is
+ // the whole operation here rather than a courtesy before a kill: nothing is
+ // terminated, no port is released, and abandoning it leaves the worker
+ // exactly as it was. So the caller's budget is the operation's budget.
+ post(workerctl.PathModelUnload, func(ctx context.Context, body []byte) (any, error) {
+ var req messaging.ModelUnloadRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid model.unload request: %w", err)
+ }
+ return s.unloadModel(ctx, req), nil
+ })
+
+ post(workerctl.PathModelDelete, func(_ context.Context, body []byte) (any, error) {
+ var req messaging.ModelDeleteRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, fmt.Errorf("invalid model.delete request: %w", err)
+ }
+ return s.deleteModel(req), nil
+ })
+
+ // backend.stop drops the caller's context for the same reason model.stop
+ // does, and more plainly: it is fire-and-forget, so there is no answer for
+ // the caller to still be waiting on.
+ post(workerctl.PathBackendStop, func(_ context.Context, body []byte) (any, error) {
+ req, stopAll, err := decodeBackendStopRequest(body)
+ if err != nil {
+ return nil, fmt.Errorf("invalid backend.stop request: %w", err)
+ }
+ s.stopBackends(req, stopAll)
+ return nil, nil
+ })
+
+ post(workerctl.PathNodeStop, func(context.Context, []byte) (any, error) {
+ // The signal is sent before the 204 is written, and that ordering is
+ // safe rather than lucky: sigCh is buffered and this send never blocks,
+ // and the shutdown it starts is a graceful one that waits for this
+ // request to finish before closing the listener.
+ s.signalNodeStop()
+ return nil, nil
+ })
+
+ // The two streaming verbs. They write NDJSON rather than one JSON object,
+ // so they do not go through post.
+ mux.HandleFunc(workerctl.PathBackendInstall, s.serveInstall)
+ mux.HandleFunc(workerctl.PathBackendUpgrade, s.serveUpgrade)
+
+ mux.HandleFunc(workerctl.Prefix, func(w http.ResponseWriter, r *http.Request) {
+ // The catch-all. A path under the control prefix that no verb claims is
+ // a frontend newer than this worker, and the body says so, because a
+ // bare 404 through a tunnel is indistinguishable from a proxy fault.
+ http.Error(w, "unknown worker control path "+truncate(r.URL.Path, maxEchoedPathBytes), http.StatusNotFound)
+ })
+}
+
+// controlVerb is the shape of a JSON-in, JSON-out control handler. A nil reply
+// means the verb answers 204, which is the shape the former publish-no-reply
+// subjects take.
+//
+// An error returned here means the request could not be READ or routed, which
+// is this worker FAILING rather than answering, and it becomes a non-2xx. A
+// verb's own failure is never returned this way: it comes back as a reply with
+// Error set, on a 200. See the note at the top of this file for why the two
+// must not be confused.
+type controlVerb func(ctx context.Context, body []byte) (any, error)
+
+// postControlVerb registers one control verb on mux.
+//
+// It is the single door every control route enters through, so the POST-only
+// check, the bounded read and the 200-with-error-field shape are written once
+// rather than once per route set. The file-staging routes mount through it for
+// exactly that reason: a second registrar with its own copy of the rules is how
+// one of them ends up unbounded, or answering 500 for a verdict.
+func postControlVerb(mux *http.ServeMux, path string, h controlVerb) {
+ mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+ body, ok := readControlBody(w, r)
+ if !ok {
+ return
+ }
+ reply, err := h(r.Context(), body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if reply == nil {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if encErr := json.NewEncoder(w).Encode(reply); encErr != nil {
+ xlog.Debug("worker control reply could not be written", "path", path, "error", encErr)
+ }
+ })
+}
+
+// readControlBody enforces the two things every control verb requires of a
+// request: that it is a POST, and that its body is bounded.
+//
+// A GET is refused rather than served because a control verb is a command, and
+// a liveness probe, a link prefetch or a browser address bar must not be able
+// to stop a node.
+func readControlBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "control verbs are POST only", http.StatusMethodNotAllowed)
+ return nil, false
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxControlRequestBytes))
+ if err != nil {
+ // A body this worker could not READ is not an answer about any
+ // backend, so it must not look like one: 400 is what the frontend maps
+ // onto "the request was rejected", never onto "that model is gone".
+ http.Error(w, "reading the control request body: "+err.Error(), http.StatusBadRequest)
+ return nil, false
+ }
+ return body, true
+}
+
+// truncate bounds a caller-controlled string that is about to be echoed.
+//
+// It cuts on a rune boundary. A byte-wise cut can split a multi-byte rune, and
+// the half rune then travels as a replacement character through every log and
+// UI that reads it; phase 2 shipped exactly that defect on a refusal reason and
+// pinned the rule afterwards. utf8.RuneStart is the same predicate the cluster
+// package uses for it, so the two are one rule rather than two hand-rolled
+// copies that can drift.
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ cut := max
+ for cut > 0 && !utf8.RuneStart(s[cut]) {
+ cut--
+ }
+ return s[:cut] + "…"
+}
+
+// ndjsonStream writes the Envelope lines of one streaming control response.
+//
+// The mutex is not optional. A progress line can be emitted from the debounce
+// timer's own goroutine, so without serialization a progress write can
+// interleave with the terminal reply write and put a torn line on the wire,
+// and the frontend's contract is that the reply line is the LAST thing on the
+// body. done is what enforces that half: once the reply is written, a late
+// progress line is dropped rather than appended after it.
+type ndjsonStream struct {
+ mu sync.Mutex
+ w http.ResponseWriter
+ enc *json.Encoder
+ done bool
+}
+
+func newNDJSONStream(w http.ResponseWriter) *ndjsonStream {
+ w.Header().Set("Content-Type", workerctl.ContentTypeStream)
+ // A streaming body must not be buffered into a guessed content type: the
+ // caller reads line by line and the first line may be minutes before the
+ // last.
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.WriteHeader(http.StatusOK)
+ return &ndjsonStream{w: w, enc: json.NewEncoder(w)}
+}
+
+// progress writes one progress line. It is a no-op once the reply has been
+// written.
+func (n *ndjsonStream) progress(ev messaging.BackendInstallProgressEvent) {
+ raw, err := json.Marshal(ev)
+ if err != nil {
+ xlog.Debug("worker control progress event could not be marshalled", "error", err)
+ return
+ }
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if n.done {
+ return
+ }
+ n.write(workerctl.Envelope{Progress: raw})
+}
+
+// reply writes the single terminal reply line and closes the stream to any
+// further progress.
+func (n *ndjsonStream) reply(v any) {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ // The caller is waiting for a terminal line and will otherwise read to
+ // EOF without one, which it cannot tell apart from a truncated
+ // response. Send a reply it can decode as a failure of THIS worker's
+ // own making rather than sending nothing.
+ xlog.Error("worker control reply could not be marshalled", "error", err)
+ raw = json.RawMessage(`{"success":false,"error":"the worker could not encode its own reply"}`)
+ }
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if n.done {
+ return
+ }
+ n.write(workerctl.Envelope{Reply: raw})
+ n.done = true
+}
+
+// write encodes one envelope and flushes it. Callers hold n.mu.
+func (n *ndjsonStream) write(env workerctl.Envelope) {
+ if err := n.enc.Encode(env); err != nil {
+ xlog.Debug("worker control stream line could not be written", "error", err)
+ return
+ }
+ if f, ok := n.w.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
+// serveInstall answers backend.install, streaming download progress ahead of
+// the single terminal reply.
+//
+// The NATS handler this replaces ran its work on a fresh goroutine so a slow
+// install could not head-of-line-block the one subscription every install
+// arrived on. Over HTTP each request already has its own goroutine, so there is
+// nothing left to block and no goroutine is started here. Per-backend
+// serialization is unchanged and still comes from lockBackend, which is what
+// actually prevented two requests racing the gallery directory.
+func (s *backendSupervisor) serveInstall(w http.ResponseWriter, r *http.Request) {
+ body, ok := readControlBody(w, r)
+ if !ok {
+ return
+ }
+ var req messaging.BackendInstallRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ http.Error(w, fmt.Sprintf("invalid backend.install request: %v", err), http.StatusBadRequest)
+ return
+ }
+ xlog.Info("Serving backend.install", "backend", req.Backend, "model", req.ModelID)
+
+ stream := newNDJSONStream(w)
+
+ release := s.lockBackend(req.Backend)
+ defer release()
+
+ // req.Force=true is the legacy path used by pre-2026-05-08 masters that
+ // don't know about backend.upgrade. Honor it so a rolling update with new
+ // worker + old master keeps working; new masters send to backend.upgrade
+ // instead.
+ addr, err := s.installer()(r.Context(), req, req.Force, stream.progress)
+ if err != nil {
+ xlog.Error("Failed to install backend", "error", err)
+ stream.reply(messaging.BackendInstallReply{Success: false, Error: err.Error()})
+ return
+ }
+
+ // The address goes back exactly as the process listens on it. It used to be
+ // rewritten onto this worker's advertise host, which made the reply the
+ // worker's third advertisement site; the frontend now reads only the port
+ // out of it and dials nothing.
+ stream.reply(messaging.BackendInstallReply{Success: true, WorkerLocalAddress: addr})
+}
+
+// serveUpgrade answers backend.upgrade, a force-reinstall, on the same
+// streaming shape as install.
+func (s *backendSupervisor) serveUpgrade(w http.ResponseWriter, r *http.Request) {
+ body, ok := readControlBody(w, r)
+ if !ok {
+ return
+ }
+ var req messaging.BackendUpgradeRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ http.Error(w, fmt.Sprintf("invalid backend.upgrade request: %v", err), http.StatusBadRequest)
+ return
+ }
+ xlog.Info("Serving backend.upgrade", "backend", req.Backend)
+
+ stream := newNDJSONStream(w)
+
+ release := s.lockBackend(req.Backend)
+ defer release()
+
+ // stopped is meaningful even on the error paths: it lists processes already
+ // terminated (and ports already recycled) before the failure, so the
+ // controller must drop those rows regardless of the outcome.
+ stopped, err := s.upgrader()(r.Context(), req, stream.progress)
+ if err != nil {
+ xlog.Error("Failed to upgrade backend", "error", err)
+ stream.reply(messaging.BackendUpgradeReply{
+ Success: false,
+ Error: err.Error(),
+ StoppedProcessKeys: stopped,
+ ReportsStoppedProcesses: true,
+ })
+ return
+ }
+ stream.reply(messaging.BackendUpgradeReply{
+ Success: true,
+ StoppedProcessKeys: stopped,
+ ReportsStoppedProcesses: true,
+ })
+}
diff --git a/core/services/worker/control_routes_test.go b/core/services/worker/control_routes_test.go
new file mode 100644
index 000000000000..584e118c2362
--- /dev/null
+++ b/core/services/worker/control_routes_test.go
@@ -0,0 +1,750 @@
+package worker
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "unicode/utf8"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+ "github.com/mudler/LocalAI/pkg/model"
+ "github.com/mudler/LocalAI/pkg/system"
+)
+
+// lastReplyOf drains an NDJSON control body and decodes the single terminal
+// reply line into out. It fails the spec when the body carries no reply line,
+// which is the shape the frontend cannot recover from: it would sit reading a
+// body that already ended.
+func lastReplyOf(body io.Reader, out any) error {
+ GinkgoHelper()
+ dec := json.NewDecoder(body)
+ var reply json.RawMessage
+ for {
+ var env workerctl.Envelope
+ err := dec.Decode(&env)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ Expect(err).NotTo(HaveOccurred())
+ if env.Reply != nil {
+ reply = env.Reply
+ }
+ }
+ Expect(reply).NotTo(BeNil(), "the control body carried no terminal reply line")
+ return json.Unmarshal(reply, out)
+}
+
+// envelopeKindsOf reports the order of progress/reply lines in an NDJSON body.
+func envelopeKindsOf(body io.Reader) []string {
+ GinkgoHelper()
+ var kinds []string
+ dec := json.NewDecoder(body)
+ for {
+ var env workerctl.Envelope
+ err := dec.Decode(&env)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ Expect(err).NotTo(HaveOccurred())
+ if env.Reply != nil {
+ kinds = append(kinds, "reply")
+ continue
+ }
+ Expect(env.Progress).NotTo(BeNil(), "an envelope carried neither progress nor reply")
+ kinds = append(kinds, "progress")
+ }
+ return kinds
+}
+
+var _ = Describe("worker control routes", func() {
+ var (
+ sup *backendSupervisor
+ srv *httptest.Server
+ sigCh chan os.Signal
+ )
+
+ BeforeEach(func() {
+ sigCh = make(chan os.Signal, 1)
+ sup = &backendSupervisor{
+ cfg: &Config{},
+ nodeID: "node-under-test",
+ sigCh: sigCh,
+ processes: map[string]*backendProcess{},
+ }
+ mux := http.NewServeMux()
+ sup.RegisterControlRoutes(mux)
+ srv = httptest.NewServer(mux)
+ DeferCleanup(srv.Close)
+ })
+
+ // post marshals, POSTs, and hands back the response.
+ post := func(path string, body any) *http.Response {
+ GinkgoHelper()
+ buf, err := json.Marshal(body)
+ Expect(err).NotTo(HaveOccurred())
+ req, err := http.NewRequest(http.MethodPost, srv.URL+path, bytes.NewReader(buf))
+ Expect(err).NotTo(HaveOccurred())
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := srv.Client().Do(req)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ return resp
+ }
+
+ It("answers models.running with the worker's process table", func() {
+ resp := post(workerctl.PathModelsRunning, messaging.ModelsRunningRequest{})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.ModelsRunningReply
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
+ Expect(reply.Models).To(BeEmpty())
+ })
+
+ It("refuses a GET on a control route, so a probe cannot fire a command", func() {
+ resp, err := srv.Client().Get(srv.URL + workerctl.PathNodeStop)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed))
+ Expect(sigCh).NotTo(Receive(), "a GET must not have signalled shutdown")
+ })
+
+ It("refuses a GET on the streaming install route too", func() {
+ resp, err := srv.Client().Get(srv.URL + workerctl.PathBackendInstall)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed))
+ })
+
+ It("answers an unknown control path with 404 and a body that names the prefix", func() {
+ resp := post(workerctl.Prefix+"no-such-verb", struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusNotFound))
+ body, err := io.ReadAll(resp.Body)
+ Expect(err).NotTo(HaveOccurred())
+ // A mixed-version deployment has to be diagnosable from one line in a
+ // log, not from a bare 404 that looks like a proxy.
+ Expect(string(body)).To(ContainSubstring("control"))
+ })
+
+ It("cuts the echoed path on a rune boundary, so no half rune reaches a log", func() {
+ // The multi-byte rune is placed so that it STRADDLES the cut: a
+ // byte-wise cut lands inside it and the body carries a replacement
+ // character. Phase 2 shipped this exact defect on a refusal reason.
+ // "a" is one byte and "€" is three. The echoed string is the WHOLE
+ // path, prefix included, so the padding is sized against the prefix to
+ // put the rune across bytes max-1, max and max+1.
+ lead := maxEchoedPathBytes - len(workerctl.Prefix) - 1
+ straddle := strings.Repeat("a", lead) + "€" + strings.Repeat("b", 64)
+ resp := post(workerctl.Prefix+straddle, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusNotFound))
+ body, err := io.ReadAll(resp.Body)
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(utf8.ValidString(string(body))).To(BeTrue(), "the 404 body must stay valid UTF-8")
+ Expect(string(body)).NotTo(ContainSubstring("\uFFFD"), "the cut split a rune")
+ // And the whole rune is dropped rather than kept past the bound.
+ Expect(string(body)).NotTo(ContainSubstring("€"))
+ })
+
+ It("keeps a rune that ends exactly on the bound, so the cut is not off by one", func() {
+ // Here the rune's last byte is at maxEchoedPathBytes-1, so it fits
+ // entirely and must survive. A cut that walked back unconditionally
+ // would drop it and this spec would catch that.
+ lead := maxEchoedPathBytes - len(workerctl.Prefix) - 3
+ exact := strings.Repeat("a", lead) + "€" + strings.Repeat("b", 64)
+ resp := post(workerctl.Prefix+exact, struct{}{})
+ body, err := io.ReadAll(resp.Body)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(utf8.ValidString(string(body))).To(BeTrue())
+ Expect(string(body)).To(ContainSubstring("€"))
+ })
+
+ It("bounds the unknown path it echoes back, so a long URL cannot be reflected wholesale", func() {
+ long := strings.Repeat("a", 4096)
+ resp := post(workerctl.Prefix+long, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusNotFound))
+ body, err := io.ReadAll(resp.Body)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(len(body)).To(BeNumerically("<", 512))
+ })
+
+ It("reports a malformed request body as 400 and does not touch the process table", func() {
+ resp, err := srv.Client().Post(srv.URL+workerctl.PathModelStop, "application/json",
+ strings.NewReader("{not json"))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ })
+
+ It("refuses a request body larger than the control bound", func() {
+ // The body is DELIBERATELY well-formed JSON for the verb it is sent to.
+ // A body of garbage would be rejected by the decoder whether or not the
+ // bound exists, so a spec built on one is green with the bound removed
+ // and pins nothing. This one is refused only because of the bound.
+ oversized := []byte(`{"process_key":"` + strings.Repeat("a", maxControlRequestBytes) + `"}`)
+ Expect(len(oversized)).To(BeNumerically(">", maxControlRequestBytes))
+ resp, err := srv.Client().Post(srv.URL+workerctl.PathModelStop, "application/json",
+ bytes.NewReader(oversized))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ })
+
+ It("accepts a large but in-bound body, so the bound is a ceiling and not a shape", func() {
+ // BackendInstallRequest.BackendGalleries is a serialized gallery list of
+ // a few hundred kilobytes on a real cluster, so the bound has to sit
+ // well above that rather than at the size of a typical request.
+ sup.installFn = func(context.Context, messaging.BackendInstallRequest, bool,
+ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ return "127.0.0.1:1", nil
+ }
+ big := []byte(`{"backend":"mock","backend_galleries":"` + strings.Repeat("a", 1<<20) + `"}`)
+ Expect(len(big)).To(BeNumerically("<", maxControlRequestBytes))
+ resp, err := srv.Client().Post(srv.URL+workerctl.PathBackendInstall, "application/json",
+ bytes.NewReader(big))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ })
+
+ It("answers backend.stop with 204, the shape a fire-and-forget verb takes", func() {
+ resp := post(workerctl.PathBackendStop, messaging.BackendStopRequest{Backend: "no-such-backend"})
+ Expect(resp.StatusCode).To(Equal(http.StatusNoContent))
+ })
+
+ It("answers node.stop with 204 and signals shutdown", func() {
+ resp := post(workerctl.PathNodeStop, struct{}{})
+ Expect(resp.StatusCode).To(Equal(http.StatusNoContent))
+ Eventually(sigCh).Should(Receive(Equal(os.Signal(syscall.SIGTERM))))
+ })
+
+ It("answers model.unload with the worker's own reply rather than a transport error", func() {
+ // Nothing is loaded, so there is nothing to free and the true answer is
+ // success.
+ resp := post(workerctl.PathModelUnload, messaging.ModelUnloadRequest{ModelName: "m"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.ModelUnloadReply
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
+ Expect(reply.Success).To(BeTrue())
+ })
+
+ It("reports a failed Free as a failed unload, not as success", func() {
+ // A worker that answers "done" about work it did not do is the fourth
+ // kind of answer in this programme's taxonomy, and it is the one acted
+ // on: the frontend's only caller of unload is EvictLRU, so a false yes
+ // tells the scheduler VRAM was released and lets it place the next
+ // model on a node still holding the old one.
+ dead, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ addr := dead.Addr().String()
+ Expect(dead.Close()).To(Succeed())
+
+ resp := post(workerctl.PathModelUnload, messaging.ModelUnloadRequest{ModelName: "m", Address: addr})
+ // Still a 200: the WORKER answered. Only the verdict inside is negative.
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.ModelUnloadReply
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ Expect(reply.Error).To(ContainSubstring(addr))
+ })
+
+ It("answers model.stop for an unknown process with the worker's verdict, not a 5xx", func() {
+ // The whole invariant of the phase: "that backend is not there" is the
+ // WORKER answering, and it must never arrive as the status code a
+ // frontend reads as "I could not reach that worker".
+ resp := post(workerctl.PathModelStop, messaging.ModelStopRequest{ProcessKey: "ghost#0"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.ModelStopReply
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
+ Expect(reply.Matched).To(BeFalse())
+ Expect(reply.ProcessKey).To(Equal("ghost#0"))
+ })
+
+ Context("streaming install", func() {
+ It("writes every progress line before the single terminal reply line", func() {
+ sup.installFn = func(_ context.Context, _ messaging.BackendInstallRequest, _ bool,
+ progress func(messaging.BackendInstallProgressEvent)) (string, error) {
+ progress(messaging.BackendInstallProgressEvent{Percentage: 50})
+ progress(messaging.BackendInstallProgressEvent{Percentage: 100})
+ return "127.0.0.1:50051", nil
+ }
+ resp := post(workerctl.PathBackendInstall, messaging.BackendInstallRequest{Backend: "mock", OpID: "op-1"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ Expect(resp.Header.Get("Content-Type")).To(Equal(workerctl.ContentTypeStream))
+ Expect(envelopeKindsOf(resp.Body)).To(Equal([]string{"progress", "progress", "reply"}))
+ })
+
+ It("carries the address the backend actually listens on back in the reply", func() {
+ sup.installFn = func(_ context.Context, _ messaging.BackendInstallRequest, _ bool,
+ _ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ return "127.0.0.1:50099", nil
+ }
+ resp := post(workerctl.PathBackendInstall, messaging.BackendInstallRequest{Backend: "mock"})
+ var reply messaging.BackendInstallReply
+ Expect(lastReplyOf(resp.Body, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeTrue())
+ Expect(reply.WorkerLocalAddress).To(Equal("127.0.0.1:50099"))
+ })
+
+ It("still writes a terminal reply line when the install fails", func() {
+ sup.installFn = func(_ context.Context, _ messaging.BackendInstallRequest, _ bool,
+ _ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ return "", errors.New("boom")
+ }
+ resp := post(workerctl.PathBackendInstall, messaging.BackendInstallRequest{Backend: "mock", OpID: "op-2"})
+ // 200 with a failed reply, not 500: the WORKER answered, and a 5xx
+ // is what the frontend reads as the worker not answering at all.
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.BackendInstallReply
+ Expect(lastReplyOf(resp.Body, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ Expect(reply.Error).To(ContainSubstring("boom"))
+ })
+
+ It("passes the request's Force flag through, which is the legacy upgrade path", func() {
+ forced := make(chan bool, 1)
+ sup.installFn = func(_ context.Context, _ messaging.BackendInstallRequest, force bool,
+ _ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ forced <- force
+ return "127.0.0.1:1", nil
+ }
+ post(workerctl.PathBackendInstall, messaging.BackendInstallRequest{Backend: "mock", Force: true})
+ Expect(forced).To(Receive(BeTrue()))
+ })
+
+ It("gives the install the caller's context, so a frontend that gave up stops the work", func() {
+ started := make(chan struct{})
+ observed := make(chan error, 1)
+ // abandon releases the handler if the context never arrives, so a
+ // build where the caller's budget was dropped fails this spec
+ // instead of parking a goroutine until the suite times out.
+ abandon := make(chan struct{})
+ DeferCleanup(func() { close(abandon) })
+ sup.installFn = func(ctx context.Context, _ messaging.BackendInstallRequest, _ bool,
+ _ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ close(started)
+ select {
+ case <-ctx.Done():
+ case <-abandon:
+ }
+ observed <- ctx.Err()
+ return "", ctx.Err()
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ srv.URL+workerctl.PathBackendInstall, strings.NewReader(`{"backend":"mock"}`))
+ Expect(err).NotTo(HaveOccurred())
+ go func() {
+ defer GinkgoRecover()
+ resp, derr := srv.Client().Do(req)
+ if derr == nil {
+ _ = resp.Body.Close()
+ }
+ }()
+ Eventually(started).Should(BeClosed())
+ cancel()
+ Eventually(observed).Should(Receive(MatchError(context.Canceled)))
+ })
+
+ It("reports a malformed install body as 400 rather than as a failed install", func() {
+ resp, err := srv.Client().Post(srv.URL+workerctl.PathBackendInstall, "application/json",
+ strings.NewReader("{not json"))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
+ })
+ })
+
+ Context("streaming upgrade", func() {
+ It("writes progress before the terminal reply and reports what it stopped", func() {
+ sup.upgradeFn = func(_ context.Context, _ messaging.BackendUpgradeRequest,
+ progress func(messaging.BackendInstallProgressEvent)) ([]string, error) {
+ progress(messaging.BackendInstallProgressEvent{Percentage: 10})
+ return []string{"m#0"}, nil
+ }
+ resp := post(workerctl.PathBackendUpgrade, messaging.BackendUpgradeRequest{Backend: "mock", OpID: "op-3"})
+ Expect(resp.Header.Get("Content-Type")).To(Equal(workerctl.ContentTypeStream))
+ body, err := io.ReadAll(resp.Body)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(envelopeKindsOf(bytes.NewReader(body))).To(Equal([]string{"progress", "reply"}))
+ var reply messaging.BackendUpgradeReply
+ Expect(lastReplyOf(bytes.NewReader(body), &reply)).To(Succeed())
+ Expect(reply.Success).To(BeTrue())
+ Expect(reply.StoppedProcessKeys).To(Equal([]string{"m#0"}))
+ Expect(reply.ReportsStoppedProcesses).To(BeTrue())
+ })
+
+ It("reports the processes it stopped even when the upgrade then failed", func() {
+ // stopped is meaningful on the error path: those ports are already
+ // recycled, so the controller must drop their rows regardless.
+ sup.upgradeFn = func(_ context.Context, _ messaging.BackendUpgradeRequest,
+ _ func(messaging.BackendInstallProgressEvent)) ([]string, error) {
+ return []string{"m#0"}, errors.New("upgrade boom")
+ }
+ resp := post(workerctl.PathBackendUpgrade, messaging.BackendUpgradeRequest{Backend: "mock"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.BackendUpgradeReply
+ Expect(lastReplyOf(resp.Body, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ Expect(reply.Error).To(ContainSubstring("upgrade boom"))
+ Expect(reply.StoppedProcessKeys).To(Equal([]string{"m#0"}))
+ Expect(reply.ReportsStoppedProcesses).To(BeTrue())
+ })
+ })
+})
+
+// These specs pin the PRODUCTION wiring rather than the handler. Every defect
+// this branch has shipped in the last two tasks was a call site no spec
+// touched, and "the control plane is mounted, on the same listener, behind the
+// same token" is exactly that kind of fact: the handlers can be perfect and a
+// worker that never mounts them answers 404 to every command while looking
+// healthy on /healthz.
+var _ = Describe("the worker's HTTP server", func() {
+ const token = "worker-token"
+
+ var (
+ srv *http.Server
+ base string
+ sup *backendSupervisor
+ )
+
+ BeforeEach(func() {
+ dir := GinkgoT().TempDir()
+ st, err := system.GetSystemState(
+ system.WithModelPath(dir),
+ system.WithBackendPath(filepath.Join(dir, "backends")),
+ system.WithBackendSystemPath(filepath.Join(dir, "backends-system")),
+ )
+ Expect(err).NotTo(HaveOccurred())
+ sup = &backendSupervisor{
+ cfg: &Config{ModelsPath: dir},
+ systemState: st,
+ nodeID: "node-under-test",
+ sigCh: make(chan os.Signal, 1),
+ processes: map[string]*backendProcess{},
+ // This Describe is about MOUNTING, so the two verbs that would
+ // otherwise reach a gallery are scripted. Everything else runs its
+ // real body against an empty worker.
+ installFn: func(context.Context, messaging.BackendInstallRequest, bool,
+ func(messaging.BackendInstallProgressEvent)) (string, error) {
+ return "127.0.0.1:50051", nil
+ },
+ upgradeFn: func(context.Context, messaging.BackendUpgradeRequest,
+ func(messaging.BackendInstallProgressEvent)) ([]string, error) {
+ return nil, nil
+ },
+ }
+ // A real object store, because the four file verbs are only mounted for
+ // a worker that has one, and the mounting assertion below walks every
+ // path this package names.
+ store, err := storage.NewFilesystemStore(filepath.Join(dir, "objectstore"))
+ Expect(err).NotTo(HaveOccurred())
+ stagingFM, err := storage.NewFileManager(store, filepath.Join(dir, "..", "cache"))
+ Expect(err).NotTo(HaveOccurred())
+
+ srv, err = startWorkerHTTPServer("127.0.0.1:0", filepath.Join(dir, "staging"), dir,
+ filepath.Join(dir, "data"), token, &nodes.WorkerReadiness{}, sup, sup.cfg, stagingFM, nil)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { nodes.ShutdownFileTransferServer(srv) })
+ Expect(srv.Addr).NotTo(BeEmpty(), "the worker HTTP server must report the address it bound")
+ base = "http://" + srv.Addr
+ })
+
+ postCtl := func(path, bearer string) *http.Response {
+ GinkgoHelper()
+ req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader("{}"))
+ Expect(err).NotTo(HaveOccurred())
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ return resp
+ }
+
+ It("serves the control plane on the same listener as the file routes", func() {
+ resp := postCtl(workerctl.PathModelsRunning, token)
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ var reply messaging.ModelsRunningReply
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
+ Expect(reply.Models).To(BeEmpty())
+ })
+
+ It("puts the control plane behind the registration token", func() {
+ Expect(postCtl(workerctl.PathModelsRunning, "").StatusCode).To(Equal(http.StatusUnauthorized))
+ Expect(postCtl(workerctl.PathModelsRunning, "wrong").StatusCode).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("serves no file verb at all when the deployment configured no object store", func() {
+ // Not a degraded mount: a worker with nowhere to stage to answers the
+ // four file paths the way a build that never had them does, which is
+ // the 404 the frontend already reads as "this worker does not serve
+ // that verb" rather than as a file that is not there.
+ dir := GinkgoT().TempDir()
+ bare, err := startWorkerHTTPServer("127.0.0.1:0", filepath.Join(dir, "staging"), dir,
+ filepath.Join(dir, "data"), token, &nodes.WorkerReadiness{}, sup, sup.cfg, nil, nil)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { nodes.ShutdownFileTransferServer(bare) })
+
+ for _, p := range []string{
+ workerctl.PathFilesEnsure, workerctl.PathFilesStage,
+ workerctl.PathFilesTemp, workerctl.PathFilesListDir,
+ } {
+ req, reqErr := http.NewRequest(http.MethodPost, "http://"+bare.Addr+p, strings.NewReader("{}"))
+ Expect(reqErr).NotTo(HaveOccurred())
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, doErr := http.DefaultClient.Do(req)
+ Expect(doErr).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ Expect(resp.StatusCode).To(Equal(http.StatusNotFound), "%s answered without an object store", p)
+ }
+ })
+
+ It("mounts every control verb, not just the one this spec reads", func() {
+ for _, p := range workerctl.AllPaths() {
+ if p == workerctl.PathNodeStop {
+ // Firing it would tear down the worker under the other specs.
+ continue
+ }
+ resp := postCtl(p, token)
+ Expect(resp.StatusCode).NotTo(Equal(http.StatusNotFound), "control verb %q is not mounted", p)
+ Expect(resp.StatusCode).NotTo(Equal(http.StatusUnauthorized), "control verb %q rejected a valid token", p)
+ }
+ })
+})
+
+// lockedRecorder is a ResponseWriter safe for concurrent writes. httptest's
+// recorder is not, and the concurrency spec below is about what ndjsonStream
+// serializes, not about what the recorder does.
+type lockedRecorder struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+ hdr http.Header
+}
+
+func newLockedRecorder() *lockedRecorder { return &lockedRecorder{hdr: http.Header{}} }
+
+func (l *lockedRecorder) Header() http.Header { return l.hdr }
+
+func (l *lockedRecorder) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.buf.Write(p)
+}
+
+func (l *lockedRecorder) WriteHeader(int) {}
+
+func (l *lockedRecorder) body() []byte {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return append([]byte(nil), l.buf.Bytes()...)
+}
+
+var _ = Describe("the NDJSON control stream", func() {
+ It("drops a progress line that arrives after the reply, so the reply stays last", func() {
+ // The debounce timer emits from its own goroutine and can fire after
+ // the install has already returned. Appending that line would break the
+ // one thing the frontend relies on to stop reading.
+ rec := httptest.NewRecorder()
+ stream := newNDJSONStream(rec)
+ stream.progress(messaging.BackendInstallProgressEvent{Percentage: 10})
+ stream.reply(messaging.BackendInstallReply{Success: true})
+ stream.progress(messaging.BackendInstallProgressEvent{Percentage: 100})
+
+ Expect(envelopeKindsOf(bytes.NewReader(rec.Body.Bytes()))).To(Equal([]string{"progress", "reply"}))
+ })
+
+ It("writes at most one reply line even when reply is called twice", func() {
+ rec := httptest.NewRecorder()
+ stream := newNDJSONStream(rec)
+ stream.reply(messaging.BackendInstallReply{Success: true})
+ stream.reply(messaging.BackendInstallReply{Success: false, Error: "second"})
+
+ Expect(envelopeKindsOf(bytes.NewReader(rec.Body.Bytes()))).To(Equal([]string{"reply"}))
+ Expect(rec.Body.String()).NotTo(ContainSubstring("second"))
+ })
+
+ It("names the streaming content type and refuses to let it be sniffed", func() {
+ rec := httptest.NewRecorder()
+ newNDJSONStream(rec)
+ Expect(rec.Header().Get("Content-Type")).To(Equal(workerctl.ContentTypeStream))
+ Expect(rec.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
+ })
+
+ It("serializes concurrent progress against the reply, so no line is torn", func() {
+ rec := newLockedRecorder()
+ stream := newNDJSONStream(rec)
+
+ const writers = 16
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(writers)
+ for i := 0; i < writers; i++ {
+ go func(n int) {
+ defer GinkgoRecover()
+ defer wg.Done()
+ <-start
+ stream.progress(messaging.BackendInstallProgressEvent{Percentage: float64(n)})
+ }(i)
+ }
+ close(start)
+ stream.reply(messaging.BackendInstallReply{Success: true})
+ wg.Wait()
+
+ kinds := envelopeKindsOf(bytes.NewReader(rec.body()))
+ Expect(kinds).NotTo(BeEmpty())
+ Expect(kinds[len(kinds)-1]).To(Equal("reply"), "the reply line must be the last line on the body")
+ for _, k := range kinds[:len(kinds)-1] {
+ Expect(k).To(Equal("progress"))
+ }
+ })
+})
+
+// These specs drive the REAL installBackend and upgradeBackend, with no
+// installFn override, so the progress wiring inside them is exercised end to
+// end through the carrier. Every other install spec scripts installFn, which
+// leaves the one line that decides whether a caller sees any progress at all
+// pinned by nothing.
+var _ = Describe("the real install progress wiring", func() {
+ var (
+ sup *backendSupervisor
+ srv *httptest.Server
+ )
+
+ BeforeEach(func() {
+ dir := GinkgoT().TempDir()
+ st, err := system.GetSystemState(
+ system.WithModelPath(dir),
+ system.WithBackendPath(filepath.Join(dir, "backends")),
+ system.WithBackendSystemPath(filepath.Join(dir, "backends-system")),
+ )
+ Expect(err).NotTo(HaveOccurred())
+ sup = &backendSupervisor{
+ cfg: &Config{ModelsPath: dir, BackendsPath: filepath.Join(dir, "backends")},
+ systemState: st,
+ ml: model.NewModelLoader(st),
+ nodeID: "node-under-test",
+ sigCh: make(chan os.Signal, 1),
+ processes: map[string]*backendProcess{},
+ // Empty on purpose. The install cannot succeed, which is the point:
+ // what is under test is that the caller is told what the worker is
+ // doing and then told it failed, not that a download works.
+ galleries: nil,
+ }
+ mux := http.NewServeMux()
+ sup.RegisterControlRoutes(mux)
+ srv = httptest.NewServer(mux)
+ DeferCleanup(srv.Close)
+ })
+
+ postJSON := func(path string, body any) *http.Response {
+ GinkgoHelper()
+ buf, err := json.Marshal(body)
+ Expect(err).NotTo(HaveOccurred())
+ resp, err := srv.Client().Post(srv.URL+path, "application/json", bytes.NewReader(buf))
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = resp.Body.Close() })
+ return resp
+ }
+
+ // envelopesOf decodes a whole NDJSON body into its envelopes.
+ envelopesOf := func(body io.Reader) []workerctl.Envelope {
+ GinkgoHelper()
+ var out []workerctl.Envelope
+ dec := json.NewDecoder(body)
+ for {
+ var env workerctl.Envelope
+ err := dec.Decode(&env)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ Expect(err).NotTo(HaveOccurred())
+ out = append(out, env)
+ }
+ return out
+ }
+
+ It("streams a resolving line before the failure, from the real installBackend", func() {
+ resp := postJSON(workerctl.PathBackendInstall,
+ messaging.BackendInstallRequest{Backend: "no-such-backend", OpID: "op-real"})
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+
+ envs := envelopesOf(resp.Body)
+ Expect(envs).NotTo(BeEmpty())
+ Expect(envs[0].Progress).NotTo(BeNil(), "the caller must see a line before the gallery work starts")
+
+ var ev messaging.BackendInstallProgressEvent
+ Expect(json.Unmarshal(envs[0].Progress, &ev)).To(Succeed())
+ Expect(ev.Phase).To(Equal(messaging.PhaseResolving))
+ Expect(ev.OpID).To(Equal("op-real"))
+ Expect(ev.NodeID).To(Equal("node-under-test"))
+ Expect(ev.Backend).To(Equal("no-such-backend"))
+
+ last := envs[len(envs)-1]
+ Expect(last.Reply).NotTo(BeNil())
+ var reply messaging.BackendInstallReply
+ Expect(json.Unmarshal(last.Reply, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ })
+
+ It("streams nothing but the reply when the caller asked for no progress", func() {
+ // An empty OpID is a reconciler-driven retry. It must not be given a
+ // progress stream, and it must still get its terminal line.
+ resp := postJSON(workerctl.PathBackendInstall,
+ messaging.BackendInstallRequest{Backend: "no-such-backend"})
+ Expect(envelopeKindsOf(resp.Body)).To(Equal([]string{"reply"}))
+ })
+
+ It("streams a resolving line from the real upgradeBackend too", func() {
+ resp := postJSON(workerctl.PathBackendUpgrade,
+ messaging.BackendUpgradeRequest{Backend: "no-such-backend", OpID: "op-real-upgrade"})
+ envs := envelopesOf(resp.Body)
+ Expect(envs).NotTo(BeEmpty())
+ Expect(envs[0].Progress).NotTo(BeNil())
+
+ var ev messaging.BackendInstallProgressEvent
+ Expect(json.Unmarshal(envs[0].Progress, &ev)).To(Succeed())
+ Expect(ev.Phase).To(Equal(messaging.PhaseResolving))
+ Expect(ev.OpID).To(Equal("op-real-upgrade"))
+
+ var reply messaging.BackendUpgradeReply
+ Expect(json.Unmarshal(envs[len(envs)-1].Reply, &reply)).To(Succeed())
+ Expect(reply.Success).To(BeFalse())
+ })
+
+ It("hands the gallery no download callback when either half of the guard is missing", func() {
+ // A nil callback is what puts the gallery on its silent path, and both
+ // halves have to be checked: a caller with an OpID but no sink is the
+ // shape that would nil-panic on the resolving emit.
+ collected := func(messaging.BackendInstallProgressEvent) {}
+
+ cb, flush := sup.startProgress("", "b", collected)
+ Expect(cb).To(BeNil())
+ Expect(flush).NotTo(BeNil())
+ flush()
+
+ cb, flush = sup.startProgress("op", "b", nil)
+ Expect(cb).To(BeNil())
+ Expect(flush).NotTo(BeNil())
+ flush()
+
+ cb, flush = sup.startProgress("op", "b", collected)
+ Expect(cb).NotTo(BeNil())
+ flush()
+ })
+})
diff --git a/core/services/worker/file_stager_roundtrip_test.go b/core/services/worker/file_stager_roundtrip_test.go
new file mode 100644
index 000000000000..1c8e455305d1
--- /dev/null
+++ b/core/services/worker/file_stager_roundtrip_test.go
@@ -0,0 +1,207 @@
+package worker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The frontend's S3 file stager against the REAL worker file-staging routes: a
+// real object store both sides share, the real handlers mounted through the
+// real nodes.AuthenticatedRoutes so the real bearer check runs first, reached
+// by a real nodes.ControlClient over a real HTTP transport.
+//
+// It lives in this package for the same reason the control-client roundtrip
+// does: the dependency runs worker -> nodes, so the frontend half cannot be
+// exercised against the real handler from the other side without an import
+// cycle. A spec on either side alone proves only that side agrees with itself;
+// the path literals, the JSON shapes and the status codes are pinned together
+// only here.
+var _ = Describe("the frontend's file stager against the real worker", func() {
+ const (
+ token = "s3cret-registration-token"
+ nodeID = "staging-worker"
+ )
+
+ var (
+ stager *nodes.S3FileStager
+ store *storage.FilesystemStore
+ workerFM *storage.FileManager
+ modelsDir string
+ srvAddr string
+ )
+
+ newStager := func(tok string) *nodes.S3FileStager {
+ GinkgoHelper()
+ frontendFM, err := storage.NewFileManager(store, GinkgoT().TempDir())
+ Expect(err).NotTo(HaveOccurred())
+ control := nodes.NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", srvAddr)
+ }
+ }, tok)
+ return nodes.NewS3FileStager(frontendFM, control)
+ }
+
+ BeforeEach(func() {
+ dir := GinkgoT().TempDir()
+ modelsDir = filepath.Join(dir, "worker", "models")
+ Expect(os.MkdirAll(modelsDir, 0o750)).To(Succeed())
+
+ var err error
+ store, err = storage.NewFilesystemStore(filepath.Join(dir, "objectstore"))
+ Expect(err).NotTo(HaveOccurred())
+
+ cfg := &Config{ModelsPath: modelsDir}
+ workerFM, err = storage.NewFileManager(store, filepath.Join(dir, "worker", "cache"))
+ Expect(err).NotTo(HaveOccurred())
+
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ srv, err := nodes.StartFileTransferServerWithRoutes(lis,
+ filepath.Join(dir, "worker", "staging"), modelsDir, filepath.Join(dir, "worker", "data"),
+ token, config.DefaultMaxUploadSize, nil,
+ &nodes.AuthenticatedRoutes{
+ Prefix: workerctl.Prefix,
+ Register: func(mux *http.ServeMux) {
+ cfg.RegisterFileControlRoutes(mux, workerFM)
+ },
+ })
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { _ = srv.Close() })
+
+ srvAddr = lis.Addr().String()
+ stager = newStager(token)
+ })
+
+ It("puts a frontend file in the store and has the worker fetch it", func() {
+ local := filepath.Join(GinkgoT().TempDir(), "model.gguf")
+ Expect(os.WriteFile(local, []byte("checkpoint bytes"), 0o600)).To(Succeed())
+
+ remote, err := stager.EnsureRemote(context.Background(), nodeID, local, storage.ModelKey("rt/model.gguf"))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(remote).To(BeAnExistingFile())
+ Expect(os.ReadFile(remote)).To(Equal([]byte("checkpoint bytes")))
+ })
+
+ It("allocates a temp file on the worker", func() {
+ remote, err := stager.AllocRemoteTemp(context.Background(), nodeID)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(remote).To(BeAnExistingFile())
+ })
+
+ It("allocates and downloads into the SAME cache directory", func() {
+ // The staging cache root is derived once and read by two things: the
+ // FileManager caches downloads into it, and the temp verb allocates
+ // inside it. Two derivations that drifted would each stay self
+ // consistent, so nothing else in this suite would notice; what would
+ // notice is an operator whose disk budget covers one of the two.
+ local := filepath.Join(GinkgoT().TempDir(), "same-root.gguf")
+ Expect(os.WriteFile(local, []byte("bytes"), 0o600)).To(Succeed())
+ downloaded, err := stager.EnsureRemote(context.Background(), nodeID, local, storage.ModelKey("root/x.gguf"))
+ Expect(err).NotTo(HaveOccurred())
+
+ tmp, err := stager.AllocRemoteTemp(context.Background(), nodeID)
+ Expect(err).NotTo(HaveOccurred())
+
+ cacheRoot := filepath.Join(filepath.Dir(modelsDir), "cache")
+ Expect(downloaded).To(HavePrefix(cacheRoot + string(filepath.Separator)))
+ Expect(tmp).To(HavePrefix(cacheRoot + string(filepath.Separator)))
+ })
+
+ It("stages a worker file into the store", func() {
+ remote := filepath.Join(modelsDir, "result.bin")
+ Expect(os.WriteFile(remote, []byte("job output"), 0o600)).To(Succeed())
+
+ Expect(stager.StageRemoteToStore(context.Background(), nodeID, remote, "data/rt/result.bin")).To(Succeed())
+ exists, err := store.Exists(context.Background(), "data/rt/result.bin")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(exists).To(BeTrue())
+ })
+
+ It("fetches a worker file back through the store", func() {
+ remote := filepath.Join(modelsDir, "fetched.bin")
+ Expect(os.WriteFile(remote, []byte("fetch me"), 0o600)).To(Succeed())
+ dst := filepath.Join(GinkgoT().TempDir(), "local.bin")
+
+ Expect(stager.FetchRemote(context.Background(), nodeID, remote, dst)).To(Succeed())
+ Expect(os.ReadFile(dst)).To(Equal([]byte("fetch me")))
+ })
+
+ It("lists a worker directory whose listing outgrows any bus payload", func() {
+ big := filepath.Join(modelsDir, "wide")
+ Expect(os.MkdirAll(big, 0o750)).To(Succeed())
+ for i := range 4000 {
+ name := fmt.Sprintf("shard-%030d.safetensors", i)
+ Expect(os.WriteFile(filepath.Join(big, name), []byte("x"), 0o600)).To(Succeed())
+ }
+
+ files, err := stager.ListRemoteDir(context.Background(), nodeID, "models/wide")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(files).To(HaveLen(4000))
+ })
+
+ It("reports the worker's own refusal as the worker's answer", func() {
+ _, err := stager.ListRemoteDir(context.Background(), nodeID, "../../../etc")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("backend listdir failed"))
+ // It said something about a file, not about the route, so it must not
+ // be wearing the umbrella that stops a caller acting on it.
+ Expect(errors.Is(err, nodes.ErrWorkerUnroutable)).To(BeFalse())
+ })
+
+ // One rule, stated once per verb: every file-staging RPC goes through the
+ // control client, so a 401 is a failure of the ROUTE and never the worker
+ // saying a file is not there. Pinned at each verb because each verb writes
+ // the call out for itself, and a single verb that reached the worker some
+ // other way would still leave the other five green.
+ DescribeTable("reports a rejected token as unroutable, never as a verdict about a file",
+ func(call func(*nodes.S3FileStager) error) {
+ wrong := newStager("not-the-token")
+ err := call(wrong)
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, nodes.ErrWorkerUnroutable)).To(BeTrue(), "got %v", err)
+ Expect(cluster.IsWorkerAnswer(err)).To(BeFalse())
+ Expect(errors.Is(err, nodes.ErrWorkerControlUnsupported)).To(BeFalse())
+ },
+ Entry("ensure", func(s *nodes.S3FileStager) error {
+ local := filepath.Join(GinkgoT().TempDir(), "m.gguf")
+ Expect(os.WriteFile(local, []byte("x"), 0o600)).To(Succeed())
+ _, err := s.EnsureRemote(context.Background(), nodeID, local, storage.ModelKey("auth/m.gguf"))
+ return err
+ }),
+ Entry("temp", func(s *nodes.S3FileStager) error {
+ _, err := s.AllocRemoteTemp(context.Background(), nodeID)
+ return err
+ }),
+ Entry("listdir", func(s *nodes.S3FileStager) error {
+ _, err := s.ListRemoteDir(context.Background(), nodeID, "models/wide")
+ return err
+ }),
+ Entry("stage to store", func(s *nodes.S3FileStager) error {
+ return s.StageRemoteToStore(context.Background(), nodeID, filepath.Join(modelsDir, "x"), "data/x")
+ }),
+ Entry("fetch", func(s *nodes.S3FileStager) error {
+ return s.FetchRemote(context.Background(), nodeID, filepath.Join(modelsDir, "x"),
+ filepath.Join(GinkgoT().TempDir(), "out"))
+ }),
+ Entry("fetch by key", func(s *nodes.S3FileStager) error {
+ return s.FetchRemoteByKey(context.Background(), nodeID, "data/x",
+ filepath.Join(GinkgoT().TempDir(), "out"))
+ }),
+ )
+})
diff --git a/core/services/worker/file_staging.go b/core/services/worker/file_staging.go
index 019afcba9ace..9e65f2cedcb5 100644
--- a/core/services/worker/file_staging.go
+++ b/core/services/worker/file_staging.go
@@ -1,16 +1,8 @@
package worker
import (
- "context"
- "encoding/json"
- "fmt"
- "os"
"path/filepath"
"strings"
-
- "github.com/mudler/LocalAI/core/services/messaging"
- "github.com/mudler/LocalAI/core/services/storage"
- "github.com/mudler/xlog"
)
// isPathAllowed checks if path is within one of the allowed directories.
@@ -35,167 +27,3 @@ func isPathAllowed(path string, allowedDirs []string) bool {
}
return false
}
-
-// subscribeFileStaging subscribes to NATS file staging subjects for this node.
-func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, nodeID string) error {
- // Create FileManager with same S3 config as the frontend
- // TODO: propagate a caller-provided context once Config carries one
- s3Store, err := storage.NewS3Store(context.Background(), storage.S3Config{
- Endpoint: cfg.StorageURL,
- Region: cfg.StorageRegion,
- Bucket: cfg.StorageBucket,
- AccessKeyID: cfg.StorageAccessKey,
- SecretAccessKey: cfg.StorageSecretKey,
- ForcePathStyle: true,
- })
- if err != nil {
- return fmt.Errorf("initializing S3 store: %w", err)
- }
-
- cacheDir := filepath.Join(cfg.ModelsPath, "..", "cache")
- fm, err := storage.NewFileManager(s3Store, cacheDir)
- if err != nil {
- return fmt.Errorf("initializing file manager: %w", err)
- }
-
- // Subscribe: files.ensure — download S3 key to local, reply with local path
- if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesEnsure(nodeID), func(data []byte, reply func([]byte)) {
- var req struct {
- Key string `json:"key"`
- }
- if err := json.Unmarshal(data, &req); err != nil {
- replyJSON(reply, map[string]string{"error": "invalid request"})
- return
- }
-
- localPath, err := fm.Download(context.Background(), req.Key)
- if err != nil {
- xlog.Error("File ensure failed", "key", req.Key, "error", err)
- replyJSON(reply, map[string]string{"error": err.Error()})
- return
- }
-
- xlog.Debug("File ensured locally", "key", req.Key, "path", localPath)
- replyJSON(reply, map[string]string{"local_path": localPath})
- }); err != nil {
- return fmt.Errorf("subscribing to files.ensure events: %w", err)
- }
-
- // Subscribe: files.stage — upload local path to S3, reply with key
- if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesStage(nodeID), func(data []byte, reply func([]byte)) {
- var req struct {
- LocalPath string `json:"local_path"`
- Key string `json:"key"`
- }
- if err := json.Unmarshal(data, &req); err != nil {
- replyJSON(reply, map[string]string{"error": "invalid request"})
- return
- }
-
- allowedDirs := []string{cacheDir}
- if cfg.ModelsPath != "" {
- allowedDirs = append(allowedDirs, cfg.ModelsPath)
- }
- if !isPathAllowed(req.LocalPath, allowedDirs) {
- replyJSON(reply, map[string]string{"error": "path outside allowed directories"})
- return
- }
-
- if err := fm.Upload(context.Background(), req.Key, req.LocalPath); err != nil {
- xlog.Error("File stage failed", "path", req.LocalPath, "key", req.Key, "error", err)
- replyJSON(reply, map[string]string{"error": err.Error()})
- return
- }
-
- xlog.Debug("File staged to S3", "path", req.LocalPath, "key", req.Key)
- replyJSON(reply, map[string]string{"key": req.Key})
- }); err != nil {
- return fmt.Errorf("subscribing to files.stage events: %w", err)
- }
-
- // Subscribe: files.temp — allocate temp file, reply with local path
- if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesTemp(nodeID), func(data []byte, reply func([]byte)) {
- tmpDir := filepath.Join(cacheDir, "staging-tmp")
- if err := os.MkdirAll(tmpDir, 0750); err != nil {
- replyJSON(reply, map[string]string{"error": fmt.Sprintf("creating temp dir: %v", err)})
- return
- }
-
- f, err := os.CreateTemp(tmpDir, "localai-staging-*.tmp")
- if err != nil {
- replyJSON(reply, map[string]string{"error": fmt.Sprintf("creating temp file: %v", err)})
- return
- }
- localPath := f.Name()
- if err := f.Close(); err != nil {
- replyJSON(reply, map[string]string{"error": fmt.Sprintf("closing temp file: %v", err)})
- return
- }
-
- xlog.Debug("Allocated temp file", "path", localPath)
- replyJSON(reply, map[string]string{"local_path": localPath})
- }); err != nil {
- return fmt.Errorf("subscribing to files.temp events: %w", err)
- }
-
- // Subscribe: files.listdir — list files in a local directory, reply with relative paths
- if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesListDir(nodeID), func(data []byte, reply func([]byte)) {
- var req struct {
- KeyPrefix string `json:"key_prefix"`
- }
- if err := json.Unmarshal(data, &req); err != nil {
- replyJSON(reply, map[string]any{"error": "invalid request"})
- return
- }
-
- // Resolve key prefix to local directory
- dirPath := filepath.Join(cacheDir, req.KeyPrefix)
- if rel, ok := strings.CutPrefix(req.KeyPrefix, storage.ModelKeyPrefix); ok && cfg.ModelsPath != "" {
- dirPath = filepath.Join(cfg.ModelsPath, rel)
- } else if rel, ok := strings.CutPrefix(req.KeyPrefix, storage.DataKeyPrefix); ok {
- dirPath = filepath.Join(cacheDir, "..", "data", rel)
- }
-
- // Sanitize to prevent directory traversal via crafted key_prefix
- dirPath = filepath.Clean(dirPath)
- cleanCache := filepath.Clean(cacheDir)
- cleanModels := filepath.Clean(cfg.ModelsPath)
- cleanData := filepath.Clean(filepath.Join(cacheDir, "..", "data"))
- if !(strings.HasPrefix(dirPath, cleanCache+string(filepath.Separator)) ||
- dirPath == cleanCache ||
- (cleanModels != "." && strings.HasPrefix(dirPath, cleanModels+string(filepath.Separator))) ||
- dirPath == cleanModels ||
- strings.HasPrefix(dirPath, cleanData+string(filepath.Separator)) ||
- dirPath == cleanData) {
- replyJSON(reply, map[string]any{"error": "invalid key prefix"})
- return
- }
-
- var files []string
- if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if !d.IsDir() {
- rel, err := filepath.Rel(dirPath, path)
- if err != nil {
- return err
- }
- files = append(files, rel)
- }
- return nil
- }); err != nil {
- xlog.Error("Failed to list staged files", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "error", err)
- replyJSON(reply, map[string]any{"error": err.Error()})
- return
- }
-
- xlog.Debug("Listed remote dir", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "fileCount", len(files))
- replyJSON(reply, map[string]any{"files": files})
- }); err != nil {
- return fmt.Errorf("subscribing to files.listdir events: %w", err)
- }
-
- xlog.Info("Subscribed to file staging NATS subjects", "nodeID", nodeID)
- return nil
-}
diff --git a/core/services/worker/heartbeat_test.go b/core/services/worker/heartbeat_test.go
new file mode 100644
index 000000000000..5153183adb68
--- /dev/null
+++ b/core/services/worker/heartbeat_test.go
@@ -0,0 +1,88 @@
+package worker
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Worker heartbeat loop", func() {
+ var (
+ ctx context.Context
+ cancel context.CancelFunc
+ tick chan time.Time
+ sent chan struct{}
+ done chan struct{}
+ )
+
+ BeforeEach(func() {
+ ctx, cancel = context.WithCancel(context.Background())
+ tick = make(chan time.Time)
+ sent = make(chan struct{}, 8)
+ done = make(chan struct{})
+ })
+
+ AfterEach(func() {
+ cancel()
+ Eventually(done, "5s").Should(BeClosed())
+ })
+
+ // tick delivers one tick, and fails the spec rather than parking forever if
+ // the loop has stopped reading. A loop that returned early would otherwise
+ // hang the suite instead of reporting a failure.
+ fire := func() {
+ select {
+ case tick <- time.Now():
+ case <-time.After(5 * time.Second):
+ Fail("the heartbeat loop stopped reading its ticker")
+ }
+ }
+
+ run := func(send func(context.Context) error) {
+ go func() {
+ defer close(done)
+ heartbeatLoop(ctx, tick, send)
+ }()
+ }
+
+ It("posts a heartbeat on every tick", func() {
+ run(func(context.Context) error {
+ sent <- struct{}{}
+ return nil
+ })
+ for i := 0; i < 3; i++ {
+ fire()
+ Eventually(sent, "5s").Should(Receive(), "tick %d produced no heartbeat", i+1)
+ }
+ })
+
+ // The heartbeat is the worker's own answer that its process is alive, and
+ // the frontend reads absence from the tunnel it holds, aged against the
+ // reconnect grace. A loop that gave up on a failing post would let one
+ // frontend restart silence a worker for the rest of its life, and the
+ // health monitor marks a silent node offline with no grace at all.
+ It("keeps posting after a heartbeat fails", func() {
+ run(func(context.Context) error {
+ sent <- struct{}{}
+ return errors.New("frontend unreachable")
+ })
+ for i := 0; i < 3; i++ {
+ fire()
+ Eventually(sent, "5s").Should(Receive(), "tick %d produced no heartbeat after a failure", i+1)
+ }
+ })
+
+ It("stops once the shutdown context is cancelled", func() {
+ run(func(context.Context) error {
+ sent <- struct{}{}
+ return nil
+ })
+ fire()
+ Eventually(sent, "5s").Should(Receive())
+ cancel()
+ Eventually(done, "5s").Should(BeClosed())
+ })
+})
diff --git a/core/services/worker/install.go b/core/services/worker/install.go
index 122b5d266c9c..1fc3238f4ae6 100644
--- a/core/services/worker/install.go
+++ b/core/services/worker/install.go
@@ -56,11 +56,16 @@ func buildProcessKey(modelID, backend string, replicaIndex int) string {
//
// Returns the gRPC address of the backend process.
//
+// ctx is the CALLER's budget, carried in from the control request, so a
+// frontend that stopped reading stops the download rather than leaving the
+// worker pulling gigabytes for a response nobody will read. onProgress receives
+// debounced download ticks and may be nil.
+//
// ProcessKey includes the replica index so a worker with MaxReplicasPerModel>1
// can host multiple processes for the same model on distinct ports. Old
// controllers (no replica_index in the request) implicitly target replica 0,
// which preserves single-replica behavior.
-func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest, force bool) (string, error) {
+func (s *backendSupervisor) installBackend(ctx context.Context, req messaging.BackendInstallRequest, force bool, onProgress func(messaging.BackendInstallProgressEvent)) (string, error) {
processKey := buildProcessKey(req.ModelID, req.Backend, int(req.ReplicaIndex))
if !force {
@@ -129,19 +134,11 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
galleries = reqGalleries
}
- // When the master tagged this install with an OpID, stream the
- // gallery download progress back to it on the per-op NATS subject.
- // Old masters that omit OpID stay on the silent path so they keep
- // working without changes. The publisher releases its mutex before
- // every Publish so a slow link never stalls the download loop, and
- // the deferred Flush guarantees a terminal-percentage event reaches
- // the master even when the install errors out.
- var downloadCb func(file, current, total string, percentage float64)
- if req.OpID != "" && s.nats != nil {
- publisher := nodes.NewDebouncedInstallProgressPublisher(s.nats, s.nodeID, req.OpID, req.Backend, installProgressDebounce)
- downloadCb = publisher.OnDownload
- defer publisher.Flush()
- }
+ // Gallery download ticks go back on the response the caller is already
+ // reading. See startProgress for the guard and for why the flush is
+ // deferred.
+ downloadCb, flushProgress := s.startProgress(req.OpID, req.Backend, onProgress)
+ defer flushProgress()
// On upgrade, run the gallery install path even if the binary already
// exists on disk: findBackend would otherwise short-circuit and we'd
@@ -155,14 +152,14 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
if req.URI != "" {
xlog.Info("Installing backend from external URI", "backend", req.Backend, "uri", req.URI, "force", force)
if err := galleryop.InstallExternalBackend(
- context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, force, s.cfg.RequireBackendIntegrity,
+ ctx, galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, force, s.cfg.RequireBackendIntegrity,
); err != nil {
return "", fmt.Errorf("installing backend from gallery: %w", err)
}
} else {
xlog.Info("Installing backend from gallery", "backend", req.Backend, "force", force)
if err := gallery.InstallBackendFromGallery(
- context.Background(), galleries, s.systemState, s.ml, req.Backend, downloadCb, force, s.cfg.RequireBackendIntegrity,
+ ctx, galleries, s.systemState, s.ml, req.Backend, downloadCb, force, s.cfg.RequireBackendIntegrity,
); err != nil {
return "", fmt.Errorf("installing backend from gallery: %w", err)
}
@@ -191,13 +188,15 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
// It does NOT start any new gRPC process — the next routine model load via
// backend.install will spawn a fresh process picking up the new binary.
//
-// The caller is responsible for holding s.lockBackend(req.Backend).
+// The caller is responsible for holding s.lockBackend(req.Backend). ctx is the
+// caller's budget and onProgress its progress sink, with the same meaning as on
+// installBackend.
//
// It returns the process keys it terminated so the controller can drop the
// NodeModel rows addressing them: an upgrade stops every process using the
// binary and starts none back up, recycling their gRPC ports while the rows
// still point at those addresses.
-func (s *backendSupervisor) upgradeBackend(req messaging.BackendUpgradeRequest) ([]string, error) {
+func (s *backendSupervisor) upgradeBackend(ctx context.Context, req messaging.BackendUpgradeRequest, onProgress func(messaging.BackendInstallProgressEvent)) ([]string, error) {
// Stop every live process for this backend (peer replicas + the bare
// processKey). Same logic as the force branch in installBackend.
toStop := s.resolveProcessKeysForBackend(s.backendIdentity(req.Backend))
@@ -228,29 +227,22 @@ func (s *backendSupervisor) upgradeBackend(req messaging.BackendUpgradeRequest)
galleries = reqGalleries
}
- // When the master tagged this upgrade with an OpID, stream gallery download
- // progress back on the per-op subject (reused from install — an upgrade is a
- // force-reinstall). Old masters omit OpID and stay on the silent path. The
- // deferred Flush guarantees a terminal-percentage event even if the upgrade
- // errors out, so the master's per-node bar never hangs mid-download.
- var downloadCb func(file, current, total string, percentage float64)
- if req.OpID != "" && s.nats != nil {
- publisher := nodes.NewDebouncedInstallProgressPublisher(s.nats, s.nodeID, req.OpID, req.Backend, installProgressDebounce)
- downloadCb = publisher.OnDownload
- defer publisher.Flush()
- }
+ // The same sink install uses: an upgrade IS a force-reinstall, so its
+ // progress is install progress.
+ downloadCb, flushProgress := s.startProgress(req.OpID, req.Backend, onProgress)
+ defer flushProgress()
if req.URI != "" {
xlog.Info("Upgrading backend from external URI", "backend", req.Backend, "uri", req.URI)
if err := galleryop.InstallExternalBackend(
- context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, true, s.cfg.RequireBackendIntegrity,
+ ctx, galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, true, s.cfg.RequireBackendIntegrity,
); err != nil {
return stopped, fmt.Errorf("upgrading backend from external URI: %w", err)
}
} else {
xlog.Info("Upgrading backend from gallery", "backend", req.Backend)
if err := gallery.InstallBackendFromGallery(
- context.Background(), galleries, s.systemState, s.ml, req.Backend, downloadCb, true, /* force */
+ ctx, galleries, s.systemState, s.ml, req.Backend, downloadCb, true, /* force */
s.cfg.RequireBackendIntegrity,
); err != nil {
return stopped, fmt.Errorf("upgrading backend from gallery: %w", err)
@@ -302,3 +294,34 @@ func (s *backendSupervisor) lockBackend(name string) func() {
m.Lock()
return m.Unlock
}
+
+// startProgress wires one install or upgrade to its caller's progress sink.
+//
+// It returns the download callback the gallery takes, and a flush the caller
+// must defer: the debouncer buffers within its window, and the deferred flush
+// is what gets the terminal percentage to the caller even when the install
+// errors out.
+//
+// Both halves of the guard matter. An empty OpID means the caller is a
+// reconciler-driven retry that asked for no progress, and a nil sink means the
+// caller is not reading a stream at all; either way the gallery gets a nil
+// callback and takes its silent path.
+//
+// The resolving event is emitted here, before any gallery work, so the caller
+// sees a line as soon as the worker has accepted the job rather than only once
+// bytes start moving. A cold install spends minutes resolving a manifest, and
+// during that time a progress stream with nothing on it is indistinguishable
+// from one that is broken.
+func (s *backendSupervisor) startProgress(opID, backend string, onProgress func(messaging.BackendInstallProgressEvent)) (func(file, current, total string, percentage float64), func()) {
+ if opID == "" || onProgress == nil {
+ return nil, func() {}
+ }
+ publisher := nodes.NewDebouncedInstallProgressSink(onProgress, s.nodeID, opID, backend, installProgressDebounce)
+ onProgress(messaging.BackendInstallProgressEvent{
+ OpID: opID,
+ NodeID: s.nodeID,
+ Backend: backend,
+ Phase: messaging.PhaseResolving,
+ })
+ return publisher.OnDownload, publisher.Flush
+}
diff --git a/core/services/worker/lifecycle.go b/core/services/worker/lifecycle.go
index 0c30c01f3b2a..888a1f4e11ae 100644
--- a/core/services/worker/lifecycle.go
+++ b/core/services/worker/lifecycle.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"maps"
- "net"
"slices"
"syscall"
@@ -15,160 +14,70 @@ import (
"github.com/mudler/xlog"
)
-// subscribeLifecycleEvents wires every NATS subject this worker accepts to its
-// per-event handler method. Each handler lives on *backendSupervisor below;
-// keeping the dispatcher to a single line per subject makes adding a new
-// subject a 2-line patch (one line here, one new method) instead of grafting
-// onto a monolith.
-func (s *backendSupervisor) subscribeLifecycleEvents() error {
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeBackendInstall(s.nodeID), s.handleBackendInstall); err != nil {
- return fmt.Errorf("subscribing to backend install events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeBackendUpgrade(s.nodeID), s.handleBackendUpgrade); err != nil {
- return fmt.Errorf("subscribing to backend upgrade events: %w", err)
- }
- if _, err := s.nats.Subscribe(messaging.SubjectNodeBackendStop(s.nodeID), s.handleBackendStop); err != nil {
- return fmt.Errorf("subscribing to backend stop events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeBackendDelete(s.nodeID), s.handleBackendDelete); err != nil {
- return fmt.Errorf("subscribing to backend delete events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeBackendList(s.nodeID), s.handleBackendList); err != nil {
- return fmt.Errorf("subscribing to backend list events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeModelsRunning(s.nodeID), s.handleModelsRunning); err != nil {
- return fmt.Errorf("subscribing to models running events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeModelUnload(s.nodeID), s.handleModelUnload); err != nil {
- return fmt.Errorf("subscribing to model unload events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeModelStop(s.nodeID), s.handleModelStop); err != nil {
- return fmt.Errorf("subscribing to model stop events: %w", err)
- }
- if _, err := s.nats.SubscribeReply(messaging.SubjectNodeModelDelete(s.nodeID), s.handleModelDelete); err != nil {
- return fmt.Errorf("subscribing to model delete events: %w", err)
- }
- if _, err := s.nats.Subscribe(messaging.SubjectNodeStop(s.nodeID), s.handleNodeStop); err != nil {
- return fmt.Errorf("subscribing to node stop events: %w", err)
- }
- return nil
-}
-
-func (s *backendSupervisor) handleModelStop(data []byte, reply func([]byte)) {
- var req messaging.ModelStopRequest
- if err := json.Unmarshal(data, &req); err != nil {
- replyJSON(reply, messaging.ModelStopReply{Error: fmt.Sprintf("invalid request: %v", err)})
- return
- }
- replyJSON(reply, s.stopModelExact(req))
-}
-
-// handleBackendInstall is the NATS callback for backend.install — install
-// backend (idempotent: skips download if binary exists on disk) + start gRPC
-// process (request-reply).
+// The worker's lifecycle verbs.
//
-// Each request runs in its own goroutine so that a slow install on one
-// backend does NOT head-of-line-block install requests for unrelated
-// backends arriving on the same subscription. Per-backend serialization
-// is provided by lockBackend so two requests targeting the same on-disk
-// artifact don't race the gallery directory.
-func (s *backendSupervisor) handleBackendInstall(data []byte, reply func([]byte)) {
- go func() {
- xlog.Info("Received NATS backend.install event")
- var req messaging.BackendInstallRequest
- if err := json.Unmarshal(data, &req); err != nil {
- resp := messaging.BackendInstallReply{Success: false, Error: fmt.Sprintf("invalid request: %v", err)}
- replyJSON(reply, resp)
- return
- }
-
- release := s.lockBackend(req.Backend)
- defer release()
+// Each takes a decoded request and returns a reply value. They carry no
+// carrier: the HTTP control plane in control_routes.go is what decodes the
+// request, calls one of these, and encodes what comes back. Keeping the verb
+// free of its transport is what let the ten NATS subscriptions these replaced
+// be deleted without touching a line of what they actually do.
+
+// backendList answers backend.list with the backends installed in this node's
+// gallery.
+func (s *backendSupervisor) backendList() messaging.BackendListReply {
+ xlog.Info("Serving backend.list")
+ backends, err := gallery.ListSystemBackends(s.systemState)
+ if err != nil {
+ return messaging.BackendListReply{Error: err.Error()}
+ }
- // req.Force=true is the legacy path used by pre-2026-05-08 masters
- // that don't know about backend.upgrade. Honor it so a rolling
- // update with new worker + old master keeps working; new masters
- // send to backend.upgrade instead.
- addr, err := s.installBackend(req, req.Force)
- if err != nil {
- xlog.Error("Failed to install backend via NATS", "error", err)
- resp := messaging.BackendInstallReply{Success: false, Error: err.Error()}
- replyJSON(reply, resp)
- return
+ var infos []messaging.NodeBackendInfo
+ for name, b := range backends {
+ // Drop synthetic alias rows: ListSystemBackends emits an entry
+ // keyed by the alias name that re-uses the chosen concrete's
+ // metadata. The frontend can't reconstruct that aliasing
+ // faithfully from a flat NodeBackendInfo, and for upgrade
+ // detection it would surface as a phantom `` install
+ // pointing at the dev concrete's URI/digest — tricking the
+ // upgrade check into flagging the non-dev gallery entry of the
+ // same alias. Concrete and meta entries always have
+ // `name == b.Metadata.Name`, so this drops aliases only.
+ if b.Metadata != nil && b.Metadata.Name != "" && name != b.Metadata.Name {
+ continue
}
-
- advertiseAddr := addr
- advAddr := s.cfg.advertiseAddr()
- if advAddr != addr {
- _, port, err := net.SplitHostPort(addr)
- if err != nil {
- xlog.Error("Failed to parse backend listen address; using it unchanged", "addr", addr, "error", err)
- } else if advertiseHost, _, err := net.SplitHostPort(advAddr); err != nil {
- xlog.Error("Failed to parse worker advertise address; using backend listen address", "addr", advAddr, "error", err)
- } else {
- advertiseAddr = net.JoinHostPort(advertiseHost, port)
- }
+ info := messaging.NodeBackendInfo{
+ Name: name,
+ IsSystem: b.IsSystem,
+ IsMeta: b.IsMeta,
}
- resp := messaging.BackendInstallReply{Success: true, Address: advertiseAddr}
- replyJSON(reply, resp)
- }()
-}
-
-// handleBackendUpgrade is the NATS callback for backend.upgrade — force-reinstall
-// a backend (request-reply). Lives on its own subscription so a multi-minute
-// download here does NOT block the install fast-path subscription on the same
-// worker.
-func (s *backendSupervisor) handleBackendUpgrade(data []byte, reply func([]byte)) {
- go func() {
- xlog.Info("Received NATS backend.upgrade event")
- var req messaging.BackendUpgradeRequest
- if err := json.Unmarshal(data, &req); err != nil {
- resp := messaging.BackendUpgradeReply{Success: false, Error: fmt.Sprintf("invalid request: %v", err)}
- replyJSON(reply, resp)
- return
+ if b.Metadata != nil {
+ info.InstalledAt = b.Metadata.InstalledAt
+ info.GalleryURL = b.Metadata.GalleryURL
+ info.Version = b.Metadata.Version
+ info.URI = b.Metadata.URI
+ info.Digest = b.Metadata.Digest
}
+ infos = append(infos, info)
+ }
- release := s.lockBackend(req.Backend)
- defer release()
-
- // stopped is meaningful even on the error paths: it lists processes
- // already terminated (and ports already recycled) before the failure, so
- // the controller must drop those rows regardless of the outcome.
- stopped, err := s.upgradeBackend(req)
- if err != nil {
- xlog.Error("Failed to upgrade backend via NATS", "error", err)
- replyJSON(reply, messaging.BackendUpgradeReply{
- Success: false,
- Error: err.Error(),
- StoppedProcessKeys: stopped,
- ReportsStoppedProcesses: true,
- })
- return
- }
- replyJSON(reply, messaging.BackendUpgradeReply{
- Success: true,
- StoppedProcessKeys: stopped,
- ReportsStoppedProcesses: true,
- })
- }()
+ return messaging.BackendListReply{Backends: infos}
}
-// handleBackendStop is the NATS callback for backend.stop — stop a specific
-// backend process (fire-and-forget, no reply expected).
-func (s *backendSupervisor) handleBackendStop(data []byte) {
- req, stopAll, err := decodeBackendStopRequest(data)
- if err != nil {
- xlog.Error("Ignoring malformed NATS backend.stop event", "error", err)
- return
- }
+// stopBackends serves backend.stop: it terminates the processes the request
+// names, or every process when it names none.
+//
+// It reports nothing. The verb has always been fire-and-forget, and a stop that
+// could report per-process failure would be a different contract than the one
+// the frontend was written against.
+func (s *backendSupervisor) stopBackends(req messaging.BackendStopRequest, stopAll bool) {
if stopAll {
- xlog.Info("Received NATS backend.stop event (all)", "force", req.Force)
+ xlog.Info("Serving backend.stop (all)", "force", req.Force)
s.stopAllBackends(req.Force)
return
}
- xlog.Info("Received NATS backend.stop event", "backend", req.Backend, "force", req.Force)
+ xlog.Info("Serving backend.stop", "backend", req.Backend, "force", req.Force)
// The identifier may be a backend name, a model name, or an exact
- // modelID#replica key depending on the publisher; resolveStopTargets
+ // modelID#replica key depending on the caller; resolveStopTargets
// handles all three. stopBackend alone resolves only the model meanings.
for _, key := range s.resolveStopTargets(req.Backend) {
if err := s.stopBackendExact(key, req.Force); err != nil {
@@ -177,6 +86,9 @@ func (s *backendSupervisor) handleBackendStop(data []byte) {
}
}
+// decodeBackendStopRequest reads a backend.stop body. An EMPTY body means "stop
+// everything", which is the shape the frontend uses to drain a node, so it is
+// not a decode failure.
func decodeBackendStopRequest(data []byte) (messaging.BackendStopRequest, bool, error) {
if len(data) == 0 {
return messaging.BackendStopRequest{}, true, nil
@@ -188,16 +100,10 @@ func decodeBackendStopRequest(data []byte) (messaging.BackendStopRequest, bool,
return req, req.Backend == "", nil
}
-// handleBackendDelete is the NATS callback for backend.delete — stop the
-// backend process if running, then remove its files from disk (request-reply).
-func (s *backendSupervisor) handleBackendDelete(data []byte, reply func([]byte)) {
- var req messaging.BackendDeleteRequest
- if err := json.Unmarshal(data, &req); err != nil {
- resp := messaging.BackendDeleteReply{Success: false, Error: fmt.Sprintf("invalid request: %v", err)}
- replyJSON(reply, resp)
- return
- }
- xlog.Info("Received NATS backend.delete event", "backend", req.Backend)
+// deleteBackend serves backend.delete: stop the backend's processes if running,
+// then remove its files from disk.
+func (s *backendSupervisor) deleteBackend(req messaging.BackendDeleteRequest) messaging.BackendDeleteReply {
+ xlog.Info("Serving backend.delete", "backend", req.Backend)
// Resolve the backend's identity (concrete name + alias) BEFORE touching
// the filesystem: DeleteBackendFromSystem removes the metadata.json that
@@ -239,8 +145,7 @@ func (s *backendSupervisor) handleBackendDelete(data []byte, reply func([]byte))
// "backend deleted" while the process keeps serving requests.
xlog.Error("Failed to stop backend process during delete; aborting delete",
"backend", req.Backend, "processKey", key, "error", err)
- replyJSON(reply, deleteReply(false, fmt.Sprintf("could not stop running process %s: %v", key, err)))
- return
+ return deleteReply(false, fmt.Sprintf("could not stop running process %s: %v", key, err))
}
stopped = append(stopped, key)
}
@@ -248,74 +153,22 @@ func (s *backendSupervisor) handleBackendDelete(data []byte, reply func([]byte))
// Delete the backend files
if err := gallery.DeleteBackendFromSystem(s.systemState, req.Backend); err != nil {
xlog.Warn("Failed to delete backend files", "backend", req.Backend, "error", err)
- replyJSON(reply, deleteReply(false, err.Error()))
- return
+ return deleteReply(false, err.Error())
}
// Re-register backends after deletion
if err := gallery.RegisterBackends(s.systemState, s.ml); err != nil {
xlog.Error("Failed to refresh registered backends after deletion", "backend", req.Backend, "error", err)
- replyJSON(reply, deleteReply(false, err.Error()))
- return
- }
-
- replyJSON(reply, deleteReply(true, ""))
-}
-
-// handleBackendList is the NATS callback for backend.list — reply with the
-// installed backends from this node's gallery (request-reply).
-func (s *backendSupervisor) handleBackendList(data []byte, reply func([]byte)) {
- xlog.Info("Received NATS backend.list event")
- backends, err := gallery.ListSystemBackends(s.systemState)
- if err != nil {
- resp := messaging.BackendListReply{Error: err.Error()}
- replyJSON(reply, resp)
- return
- }
-
- var infos []messaging.NodeBackendInfo
- for name, b := range backends {
- // Drop synthetic alias rows: ListSystemBackends emits an entry
- // keyed by the alias name that re-uses the chosen concrete's
- // metadata. The frontend can't reconstruct that aliasing
- // faithfully from a flat NodeBackendInfo, and for upgrade
- // detection it would surface as a phantom `` install
- // pointing at the dev concrete's URI/digest — tricking the
- // upgrade check into flagging the non-dev gallery entry of the
- // same alias. Concrete and meta entries always have
- // `name == b.Metadata.Name`, so this drops aliases only.
- if b.Metadata != nil && b.Metadata.Name != "" && name != b.Metadata.Name {
- continue
- }
- info := messaging.NodeBackendInfo{
- Name: name,
- IsSystem: b.IsSystem,
- IsMeta: b.IsMeta,
- }
- if b.Metadata != nil {
- info.InstalledAt = b.Metadata.InstalledAt
- info.GalleryURL = b.Metadata.GalleryURL
- info.Version = b.Metadata.Version
- info.URI = b.Metadata.URI
- info.Digest = b.Metadata.Digest
- }
- infos = append(infos, info)
+ return deleteReply(false, err.Error())
}
- resp := messaging.BackendListReply{Backends: infos}
- replyJSON(reply, resp)
+ return deleteReply(true, "")
}
-// handleModelUnload is the NATS callback for model.unload — call gRPC Free()
-// to release GPU memory without killing the backend process (request-reply).
-func (s *backendSupervisor) handleModelUnload(data []byte, reply func([]byte)) {
- xlog.Info("Received NATS model.unload event")
- var req messaging.ModelUnloadRequest
- if err := json.Unmarshal(data, &req); err != nil {
- resp := messaging.ModelUnloadReply{Success: false, Error: fmt.Sprintf("invalid request: %v", err)}
- replyJSON(reply, resp)
- return
- }
+// unloadModel serves model.unload: a gRPC Free() that releases GPU memory
+// without killing the backend process.
+func (s *backendSupervisor) unloadModel(ctx context.Context, req messaging.ModelUnloadRequest) messaging.ModelUnloadReply {
+ xlog.Info("Serving model.unload")
// Find the backend address for this model's backend type
// The request includes an Address field if the router knows which process to target
@@ -330,44 +183,55 @@ func (s *backendSupervisor) handleModelUnload(data []byte, reply func([]byte)) {
s.mu.Unlock()
}
- if targetAddr != "" {
- // Best-effort bounded gRPC Free(). A model.unload request must not
- // occupy the NATS reply handler forever when a backend is wedged.
- client := grpc.NewClientWithToken(targetAddr, false, nil, false, s.cfg.RegistrationToken)
- freeCtx, cancel := context.WithTimeout(context.Background(), workerBackendFreeTimeout)
- if err := client.Free(freeCtx); err != nil {
- xlog.Warn("Free() failed during model.unload", "error", err, "addr", targetAddr)
+ if targetAddr == "" {
+ // Nothing is loaded here, so there is nothing to free. That is the
+ // worker's own answer and it is a true one, not a claim about work it
+ // performed.
+ return messaging.ModelUnloadReply{Success: true}
+ }
+
+ // Bounded gRPC Free(). A model.unload request must not occupy the handler
+ // forever when a backend is wedged. The bound is derived from the caller's
+ // own budget, so a caller that allowed less than workerBackendFreeTimeout
+ // is not made to wait longer than it asked for.
+ client := grpc.NewClientWithToken(targetAddr, false, nil, false, s.cfg.RegistrationToken)
+ freeCtx, cancel := context.WithTimeout(ctx, workerBackendFreeTimeout)
+ err := client.Free(freeCtx)
+ cancel()
+ if err != nil {
+ // Reported, not swallowed. This used to answer Success:true whatever
+ // Free did, which is the worker saying "done" about something it did
+ // not do: the frontend's only caller is EvictLRU, so a false yes told
+ // the scheduler VRAM had been released and let it place the next model
+ // on a node still holding the old one. A failure here is the worker's
+ // own verdict about one Free call and nothing more; it is not a
+ // statement that the node or the model is gone.
+ xlog.Warn("Free() failed during model.unload", "error", err, "addr", targetAddr)
+ return messaging.ModelUnloadReply{
+ Success: false,
+ Error: fmt.Sprintf("freeing model on %s: %v", targetAddr, err),
}
- cancel()
}
- resp := messaging.ModelUnloadReply{Success: true}
- replyJSON(reply, resp)
+ return messaging.ModelUnloadReply{Success: true}
}
-// handleModelDelete is the NATS callback for model.delete — remove model
-// files from disk (request-reply).
-func (s *backendSupervisor) handleModelDelete(data []byte, reply func([]byte)) {
- xlog.Info("Received NATS model.delete event")
- var req messaging.ModelDeleteRequest
- if err := json.Unmarshal(data, &req); err != nil {
- replyJSON(reply, messaging.ModelDeleteReply{Success: false, Error: "invalid request"})
- return
- }
+// deleteModel serves model.delete: remove a model's staged files from disk.
+func (s *backendSupervisor) deleteModel(req messaging.ModelDeleteRequest) messaging.ModelDeleteReply {
+ xlog.Info("Serving model.delete", "model", req.ModelName)
if err := gallery.DeleteStagedModelFiles(s.cfg.ModelsPath, req.ModelName); err != nil {
xlog.Warn("Failed to delete model files", "model", req.ModelName, "error", err)
- replyJSON(reply, messaging.ModelDeleteReply{Success: false, Error: err.Error()})
- return
+ return messaging.ModelDeleteReply{Success: false, Error: err.Error()}
}
- replyJSON(reply, messaging.ModelDeleteReply{Success: true})
+ return messaging.ModelDeleteReply{Success: true}
}
-// handleNodeStop is the NATS callback for node.stop — trigger the normal
-// shutdown path via sigCh so deferred cleanup runs (fire-and-forget).
-func (s *backendSupervisor) handleNodeStop(data []byte) {
- xlog.Info("Received NATS stop event — signaling shutdown")
+// signalNodeStop serves node.stop: it triggers the normal shutdown path via
+// sigCh so deferred cleanup runs, rather than exiting the process here.
+func (s *backendSupervisor) signalNodeStop() {
+ xlog.Info("Serving node.stop, signaling shutdown")
select {
case s.sigCh <- syscall.SIGTERM:
default:
diff --git a/core/services/worker/model_stop_test.go b/core/services/worker/model_stop_test.go
index 345b61a30976..c54aec821b6f 100644
--- a/core/services/worker/model_stop_test.go
+++ b/core/services/worker/model_stop_test.go
@@ -1,13 +1,17 @@
package worker
import (
+ "bytes"
"context"
"encoding/json"
"errors"
"net"
+ "net/http"
+ "net/http/httptest"
"sync/atomic"
"github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/workerctl"
process "github.com/mudler/go-processmanager"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -42,13 +46,27 @@ func startModelStopProcess() *process.Process {
return proc
}
+// requestModelStop drives the model.stop verb over the HTTP control plane the
+// worker actually serves, rather than calling the method directly. These specs
+// are the ones that pin the acknowledged-stop contract, so routing them through
+// the carrier is what keeps a routing mistake from passing them.
func requestModelStop(s *backendSupervisor, req messaging.ModelStopRequest) messaging.ModelStopReply {
+ GinkgoHelper()
data, err := json.Marshal(req)
Expect(err).NotTo(HaveOccurred())
- var response []byte
- s.handleModelStop(data, func(data []byte) { response = append([]byte(nil), data...) })
+
+ mux := http.NewServeMux()
+ s.RegisterControlRoutes(mux)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ resp, err := srv.Client().Post(srv.URL+workerctl.PathModelStop, "application/json", bytes.NewReader(data))
+ Expect(err).NotTo(HaveOccurred())
+ defer func() { _ = resp.Body.Close() }()
+ Expect(resp.StatusCode).To(Equal(http.StatusOK))
+
var reply messaging.ModelStopReply
- Expect(json.Unmarshal(response, &reply)).To(Succeed())
+ Expect(json.NewDecoder(resp.Body).Decode(&reply)).To(Succeed())
return reply
}
diff --git a/core/services/worker/models_running.go b/core/services/worker/models_running.go
index efc4700a084a..4cdb08edb15d 100644
--- a/core/services/worker/models_running.go
+++ b/core/services/worker/models_running.go
@@ -55,11 +55,3 @@ func (s *backendSupervisor) runningModels() []messaging.RunningModelInfo {
}
return running
}
-
-// handleModelsRunning answers a models.running request with this worker's live
-// process set.
-func (s *backendSupervisor) handleModelsRunning(_ []byte, reply func([]byte)) {
- running := s.runningModels()
- xlog.Debug("Answering models.running", "nodeID", s.nodeID, "count", len(running))
- replyJSON(reply, messaging.ModelsRunningReply{Models: running})
-}
diff --git a/core/services/worker/nats_connect.go b/core/services/worker/nats_connect.go
deleted file mode 100644
index 25485701d2ec..000000000000
--- a/core/services/worker/nats_connect.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package worker
-
-import (
- "fmt"
-
- "github.com/mudler/LocalAI/core/services/messaging"
-)
-
-// connectNATS opens a NATS client using JWT+seed from env or registration (env wins).
-func connectNATS(url, envJWT, envSeed, registerJWT, registerSeed string, requireAuth bool, tls messaging.TLSFiles) (*messaging.Client, error) {
- // Env credentials take precedence, but only fall back to registration when
- // the env supplied neither half — otherwise a JWT set without its seed (or
- // vice-versa) would be silently completed from a different source.
- jwt, seed := envJWT, envSeed
- if jwt == "" && seed == "" {
- jwt, seed = registerJWT, registerSeed
- }
- // A JWT without its paired seed (or vice-versa) is a misconfiguration: refuse
- // rather than silently connecting anonymously, which would look authenticated.
- if (jwt == "") != (seed == "") {
- return nil, fmt.Errorf("NATS JWT and seed must be provided together (got JWT set=%t, seed set=%t)", jwt != "", seed != "")
- }
- var opts []messaging.Option
- if jwt != "" && seed != "" {
- opts = append(opts, messaging.WithUserJWT(jwt, seed))
- } else if requireAuth {
- return nil, fmt.Errorf("NATS JWT+seed required: set LOCALAI_NATS_JWT/LOCALAI_NATS_USER_SEED or enable frontend minting")
- }
- if tls.Enabled() {
- opts = append(opts, messaging.WithTLS(tls))
- }
- return messaging.New(url, opts...)
-}
diff --git a/core/services/worker/nats_connect_test.go b/core/services/worker/nats_connect_test.go
deleted file mode 100644
index 8f554de4e944..000000000000
--- a/core/services/worker/nats_connect_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package worker
-
-import (
- "github.com/mudler/LocalAI/core/services/messaging"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("connectNATS", func() {
- It("requires JWT when requireAuth is set and no credentials are provided", func() {
- _, err := connectNATS("nats://127.0.0.1:4222", "", "", "", "", true, messaging.TLSFiles{})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("NATS JWT+seed required"))
- })
-
- // A JWT supplied without its paired seed (or vice-versa) is an operator
- // misconfiguration. Today connectNATS silently drops the unpaired credential
- // and connects anonymously, so the operator believes the link is
- // authenticated when it is not. It should refuse instead.
- It("rejects a JWT supplied without a seed instead of connecting anonymously", func() {
- client, err := connectNATS("nats://127.0.0.1:4222", "jwt-without-seed", "", "", "", false, messaging.TLSFiles{})
- if client != nil {
- client.Close()
- }
- Expect(err).To(HaveOccurred(),
- "connectNATS should reject an unpaired JWT rather than silently connecting anonymously")
- })
-})
diff --git a/core/services/worker/prefetch.go b/core/services/worker/prefetch.go
index 4aec36a99de8..b62effd74a7f 100644
--- a/core/services/worker/prefetch.go
+++ b/core/services/worker/prefetch.go
@@ -38,8 +38,8 @@ var realModelInstaller modelInstaller = func(
) error {
// enforceScan=false: workers fetch from the same gallery the master already
// trusts, and the master would have scanned at install time anyway.
- // autoloadBackendGalleries=false: the worker installs backends on demand via
- // backend.install NATS events; prefetching the backend here would race the
+ // autoloadBackendGalleries=false: the worker installs backends on demand when
+ // the frontend calls its install control route; prefetching one here would race the
// supervisor's own install path and double-trigger gallery work.
// requireBackendIntegrity=false: same reason — we're not installing a backend.
return gallery.InstallModelFromGallery(
@@ -57,13 +57,15 @@ var realModelInstaller modelInstaller = func(
// prefetchModels resolves each configured gallery ID against the model gallery
// and downloads the artifact into the worker's /models. It is called once at
-// worker startup, BEFORE the NATS lifecycle subscription, so that the steady
-// state has the file already on disk and the master never needs to stream it.
+// worker startup, BEFORE the worker registers or opens its tunnel, so that the
+// steady state has the file already on disk and the master never needs to
+// stream it.
//
// Errors are intentionally non-fatal: on a fresh worker with no outbound
// connectivity (or a misconfigured gallery JSON), we want the worker to still
// register and serve traffic — the master will fall back to pushing files
-// on-demand over NATS/HTTP, which is the pre-existing behavior. Per-model
+// on-demand over the worker's file-transfer routes, which is the pre-existing
+// behavior. Per-model
// failures are logged at warn level and the loop continues with the next ID.
//
// Idempotency comes for free from pkg/downloader.URI.DownloadFileWithContext:
@@ -98,7 +100,7 @@ func prefetchModels(
installer = realModelInstaller
}
- xlog.Info("Prefetching models from gallery before entering NATS loop", "count", len(models), "models", models)
+ xlog.Info("Prefetching models from gallery before registering", "count", len(models), "models", models)
for _, name := range models {
xlog.Info("Prefetching model", "model", name)
if err := installer(ctx, modelGalleries, backendGalleries, systemState, ml, name); err != nil {
diff --git a/core/services/worker/registration.go b/core/services/worker/registration.go
index 29d88d56c3f5..b56e2797d768 100644
--- a/core/services/worker/registration.go
+++ b/core/services/worker/registration.go
@@ -1,7 +1,6 @@
package worker
import (
- "cmp"
"fmt"
"net"
"os"
@@ -21,6 +20,10 @@ var (
// effectiveBasePort returns the port used as base for gRPC backend processes.
// Priority: Addr port → ServeAddr port → 50051
+//
+// Only the PORT of those settings is read. Their host halves name an interface
+// this worker no longer binds: every backend listens on loopback and is reached
+// through the tunnel.
func (cfg *Config) effectiveBasePort() int {
for _, addr := range []string{cfg.Addr, cfg.ServeAddr} {
if addr == "" {
@@ -70,45 +73,21 @@ func (cfg *Config) effectiveMaxPort(basePort int) int {
return cfg.GRPCMaxPort
}
-// advertiseAddr returns the address the frontend should use to reach this node.
-func (cfg *Config) advertiseAddr() string {
- if cfg.AdvertiseAddr != "" {
- return cfg.AdvertiseAddr
- }
- if cfg.Addr != "" {
- return cfg.Addr
- }
- hostname, err := os.Hostname()
- if err != nil {
- xlog.Warn("Failed to determine worker hostname; advertising localhost", "error", err)
- }
- return fmt.Sprintf("%s:%d", cmp.Or(hostname, "localhost"), cfg.effectiveBasePort())
-}
-
// resolveHTTPAddr returns the address to bind the HTTP file transfer server to.
// Uses basePort-1 so it doesn't conflict with dynamically allocated gRPC ports
// which grow upward from basePort.
+//
+// The default is loopback for the same reason backend processes are: the
+// frontend reaches this server over the tunnel, whose http tag dials whatever
+// address this returns. An operator who sets HTTPAddr explicitly still gets
+// exactly that bind (see loopbackAddr, which rewrites only a wildcard), so a
+// deployment that has some other local reason to expose the server can, and
+// nothing in the frontend depends on it.
func (cfg *Config) resolveHTTPAddr() string {
if cfg.HTTPAddr != "" {
return cfg.HTTPAddr
}
- return fmt.Sprintf("0.0.0.0:%d", cfg.effectiveBasePort()-1)
-}
-
-// advertiseHTTPAddr returns the HTTP address the frontend should use to reach
-// this node for file transfer.
-func (cfg *Config) advertiseHTTPAddr() string {
- if cfg.AdvertiseHTTPAddr != "" {
- return cfg.AdvertiseHTTPAddr
- }
- advertiseAddr := cfg.advertiseAddr()
- advHost, _, err := net.SplitHostPort(advertiseAddr)
- if err != nil {
- xlog.Warn("Invalid worker advertise address; advertising file transfer on localhost", "addr", advertiseAddr, "error", err)
- advHost = "localhost"
- }
- httpPort := cfg.effectiveBasePort() - 1
- return net.JoinHostPort(advHost, strconv.Itoa(httpPort))
+ return net.JoinHostPort(loopbackHost, strconv.Itoa(cfg.effectiveBasePort()-1))
}
// registrationBody builds the JSON body for node registration.
@@ -151,10 +130,12 @@ func (cfg *Config) registrationBody() map[string]any {
if maxReplicas < 1 {
maxReplicas = 1
}
+ // No address and no http_address: this worker has nothing inbound to
+ // advertise. It holds one outbound tunnel and the frontend reaches every
+ // service on it through that, so an address here would be a value that
+ // looks dialable, is stored, is shown, and is never dialled.
body := map[string]any{
"name": nodeName,
- "address": cfg.advertiseAddr(),
- "http_address": cfg.advertiseHTTPAddr(),
"total_vram": totalVRAM,
"available_vram": totalVRAM, // initially all VRAM is available
"gpu_vendor": gpuVendor,
diff --git a/core/services/worker/reply.go b/core/services/worker/reply.go
deleted file mode 100644
index 9700f19fa47c..000000000000
--- a/core/services/worker/reply.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package worker
-
-import (
- "encoding/json"
-
- "github.com/mudler/xlog"
-)
-
-// replyJSON marshals v to JSON and calls the reply function.
-func replyJSON(reply func([]byte), v any) {
- data, err := json.Marshal(v)
- if err != nil {
- xlog.Error("Failed to marshal NATS reply", "error", err)
- data = []byte(`{"error":"internal marshal error"}`)
- }
- reply(data)
-}
diff --git a/core/services/worker/supervisor.go b/core/services/worker/supervisor.go
index cf95e8b63aaa..3ccaf4033ae5 100644
--- a/core/services/worker/supervisor.go
+++ b/core/services/worker/supervisor.go
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"maps"
+ "net"
"os"
"path/filepath"
"slices"
+ "strconv"
"strings"
"sync"
"time"
@@ -22,10 +24,26 @@ import (
"github.com/mudler/xlog"
)
+// backendListenAddr is where a backend process binds, which is also the only
+// address anything ever reaches it on.
+//
+// It is built from loopbackHost, the constant the tunnel's grpc tag dials, so
+// "the worker binds where its tunnel dials" is one fact in one place rather
+// than two literals that can drift. A backend is reached only over this
+// worker's tunnel; a wildcard bind would publish every backend process on every
+// interface to serve a route nothing takes, and on a worker with a public
+// interface that is an unauthenticated inference server.
+func backendListenAddr(port int) string {
+ return net.JoinHostPort(loopbackHost, strconv.Itoa(port))
+}
+
// backendProcess represents a single gRPC backend process.
type backendProcess struct {
- proc *process.Process
- addr string // gRPC address (host:port)
+ proc *process.Process
+ // addr is where this process listens, and it is worker-local: see
+ // backendListenAddr. The frontend is told this string and reads only its
+ // port out of it.
+ addr string
port int
stopping bool
// backendName is the gallery backend this process was started for (e.g.
@@ -101,9 +119,15 @@ type backendSupervisor struct {
systemState *system.SystemState
galleries []config.Gallery
nodeID string
- nats messaging.MessagingClient
sigCh chan<- os.Signal // send shutdown signal instead of os.Exit
+ // installFn and upgradeFn override the two long-running verbs. Non-nil
+ // only in specs: they exist so the control plane's ROUTING can be exercised
+ // without a gallery, a registry or a real download, which is the same
+ // argument tunnelServices was extracted under. See installer/upgrader.
+ installFn installFunc
+ upgradeFn upgradeFunc
+
mu sync.Mutex
processes map[string]*backendProcess // key: backend name
nextPort int // next unhanded-out port; grows within [minPort, maxPort]
@@ -143,8 +167,8 @@ type backendSupervisor struct {
// reply and drops the NodeModel rows naming that address, a row still resolves
// to a live listener. probeHealth verifies liveness, not identity, so a port
// re-bound inside that window is dispatched to as if it were the original
-// backend. The window is a NATS round-trip plus a row delete, so seconds of
-// slack are ample.
+// backend. The window is one control-plane round-trip plus a row delete, so
+// seconds of slack are ample.
//
// Deliberately NOT derived from the controller's HealthCheckInterval or from
// the per-model miss threshold. Tying a worker-local constant to a
@@ -452,10 +476,9 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
s.mu.Unlock()
return "", fmt.Errorf("allocating gRPC port for backend %s: %w", backend, err)
}
- bindAddr := fmt.Sprintf("0.0.0.0:%d", port)
- clientAddr := fmt.Sprintf("127.0.0.1:%d", port)
+ procAddr := backendListenAddr(port)
- proc, err := s.ml.StartProcess(backendPath, backend, bindAddr)
+ proc, err := s.ml.StartProcess(backendPath, backend, procAddr)
if err != nil {
s.releasePortForKey(backend, port)
s.mu.Unlock()
@@ -476,13 +499,13 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
s.processes[backend] = &backendProcess{
proc: proc,
- addr: clientAddr,
+ addr: procAddr,
port: port,
backendName: backendName,
backendDir: backendDir,
backendDirID: dirInfo,
}
- xlog.Info("Backend process started", "backend", backend, "addr", clientAddr)
+ xlog.Info("Backend process started", "backend", backend, "addr", procAddr)
// Capture reference before unlocking for race-safe health check.
// Another goroutine could stopBackend and recycle the port while we poll.
@@ -495,7 +518,7 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
// 4s window made the worker reply Success on a not-yet-listening port,
// which manifested upstream as "connect: connection refused" on the
// frontend's first LoadModel dial.
- client := grpc.NewClientWithToken(clientAddr, false, nil, false, s.cfg.RegistrationToken)
+ client := grpc.NewClientWithToken(procAddr, false, nil, false, s.cfg.RegistrationToken)
const (
readinessPollInterval = 200 * time.Millisecond
readinessTimeout = 30 * time.Second
@@ -514,8 +537,8 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
if !s.backendStartStillValid(backend, bp) {
return "", fmt.Errorf("backend %s was stopped during startup", backend)
}
- xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", clientAddr)
- return clientAddr, nil
+ xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", procAddr)
+ return procAddr, nil
}
if healthErr != nil {
lastHealthErr = healthErr
@@ -537,7 +560,7 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
// real cause). Stop the half-started process, recycle the port, and
// surface the failure to the caller with the backend's stderr tail.
stderrTail := readLastLinesFromFile(proc.StderrPath(), 20)
- xlog.Error("Backend gRPC server not ready before deadline; aborting install", "backend", backend, "addr", clientAddr, "timeout", readinessTimeout, "healthError", lastHealthErr, "stderr", stderrTail)
+ xlog.Error("Backend gRPC server not ready before deadline; aborting install", "backend", backend, "addr", procAddr, "timeout", readinessTimeout, "healthError", lastHealthErr, "stderr", stderrTail)
if killErr := proc.Stop(); killErr != nil {
xlog.Warn("Failed to stop unready backend process", "backend", backend, "error", killErr)
}
@@ -604,7 +627,7 @@ func (s *backendSupervisor) releaseBackendStart(key string, bp *backendProcess)
// resolveProcessKeys turns a caller-supplied identifier into the set of
// process map keys it refers to. PR #9583 changed s.processes to be keyed by
-// `modelID#replicaIndex`, but external NATS handlers still pass the bare
+// `modelID#replicaIndex`, but external callers still pass the bare
// model ID — without this resolver, those lookups silently no-op'd, so
// admin "Unload model" / "Delete backend" left the worker process alive.
//
@@ -826,6 +849,12 @@ func (s *backendSupervisor) stopBackendExact(key string, force bool) error {
}
if !force {
+ // Background and not a caller's context on purpose. This Free is a
+ // courtesy before the process is killed anyway, and the stop that
+ // follows must complete for the port to be released, so binding it to
+ // a caller that may have gone would buy nothing and could abandon a
+ // half-finished stop. model.unload, where Free IS the operation, takes
+ // the caller's budget instead; see RegisterControlRoutes.
client := grpc.NewClientWithToken(bp.addr, false, nil, false, s.cfg.RegistrationToken)
freeCtx, cancel := context.WithTimeout(context.Background(), workerBackendFreeTimeout)
xlog.Debug("Calling bounded Free() before stopping backend", "backend", key, "timeout", workerBackendFreeTimeout)
@@ -872,6 +901,8 @@ func (s *backendSupervisor) stopModelExact(req messaging.ModelStopRequest) messa
s.mu.Unlock()
if !req.Force {
+ // Background, for the reason given in stopBackendExact: this is the
+ // acknowledged stop and it has to run to completion.
client := grpc.NewClientWithToken(bp.addr, false, nil, false, s.cfg.RegistrationToken)
freeCtx, cancel := context.WithTimeout(context.Background(), workerBackendFreeTimeout)
freeErr := client.Free(freeCtx)
diff --git a/core/services/worker/tunnel.go b/core/services/worker/tunnel.go
new file mode 100644
index 000000000000..fb22b3368594
--- /dev/null
+++ b/core/services/worker/tunnel.go
@@ -0,0 +1,839 @@
+package worker
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/libp2p/go-yamux/v5"
+ "github.com/mudler/xlog"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+)
+
+// The worker end of the tunnel.
+//
+// The worker DIALS OUT and never listens. It holds one WebSocket to the
+// frontend load balancer, multiplexed with yamux, and every request the
+// frontend makes of this worker arrives as a stream inside it. That is the
+// whole point: a worker behind NAT, in another cluster or on a laptop needs no
+// inbound port and no reachable address.
+//
+// This side is the yamux CLIENT and it only ACCEPTS streams; the frontend is
+// the server and it only opens them. The frontend asks, the worker answers.
+// Nothing here opens a stream, and a stream this side opened would park on the
+// frontend's accept backlog, which accepts none.
+
+const (
+ // tunnelBackoffBase is the shortest wait between reconnects, before jitter.
+ tunnelBackoffBase = 500 * time.Millisecond
+
+ // tunnelBackoffMax is the ceiling on that wait.
+ //
+ // The ceiling is the interesting half. Without one, a worker that sits
+ // through a long frontend outage backs off into hours and does not come
+ // back for a long time after the frontend does; with one, the worst case
+ // for rejoining is bounded by this. The floor and the jitter are what stop
+ // a fleet of workers from turning a rolling restart into a retry storm
+ // against the first replica back up.
+ tunnelBackoffMax = 30 * time.Second
+
+ // tunnelHealthyAfter is how long a session must last before the backoff is
+ // allowed back to its floor.
+ //
+ // Resetting on CONNECT rather than on a session that lasted is the classic
+ // way to build a reconnect storm that looks like a backoff: during a
+ // rolling restart a replica accepts the dial and dies moments later, so
+ // every attempt "succeeds" and every wait is the floor. This is set to the
+ // yamux keepalive interval, which is the shortest interval over which a
+ // session that is merely up can be told from one that is working.
+ tunnelHealthyAfter = 30 * time.Second
+
+ // tunnelHandshakeTimeout bounds the WebSocket upgrade, matching the peer
+ // link's.
+ tunnelHandshakeTimeout = 10 * time.Second
+
+ // tunnelHeaderTimeout bounds how long a stream may go without sending the
+ // request frame that says what it is for. It is present because without it
+ // a stream that sends nothing holds a goroutine and one of the session's
+ // stream slots for as long as the tunnel lives.
+ //
+ // It is generous because it does NOT bound only the frontend's own framing.
+ // An earlier version of this comment said it did, which is true on the
+ // direct path and false on the relay path that carries most of a
+ // multi-replica deployment's traffic: this timer starts when the OWNING
+ // replica opens the stream, while the frame is written by the DIALLING
+ // replica only after the relay's acceptance has travelled back to it. A
+ // whole peer-link round trip therefore runs inside this window, on a link
+ // deliberately loaded with multi-gigabyte artifacts beside token streams.
+ //
+ // The comment mattered because it was the argument for treating an expiry
+ // as the frontend's fault: framing written immediately can only be late if
+ // something is wrong with the frontend. It cannot, so an expiry is refused
+ // with cluster.ErrStreamNotServed and says nothing about a backend. See
+ // Tunnel.accept.
+ tunnelHeaderTimeout = 15 * time.Second
+)
+
+// LocalService opens a connection to one service running on this worker.
+//
+// target is the tag-specific argument from the stream's request frame, and the
+// service decides what it will accept: the frontend naming an address does not
+// oblige the worker to dial it. See loopbackService, which is what the worker
+// actually installs.
+type LocalService func(ctx context.Context, target string) (net.Conn, error)
+
+// TunnelConfig configures the tunnel a worker holds to the frontend.
+type TunnelConfig struct {
+ // FrontendURL is the same value the worker registers against
+ // (LOCALAI_REGISTER_TO). Its scheme is mapped to ws/wss here.
+ FrontendURL string
+
+ // NodeID is the identity registration assigned this worker.
+ NodeID string
+
+ // Token supplies the node's own tunnel credential.
+ //
+ // A function and not a string, and that is load-bearing rather than
+ // stylistic. The credential is re-minted on every registration, so a client
+ // that captured one at startup would keep presenting a value the frontend
+ // stopped accepting the moment anything re-registered this worker, and
+ // would lock itself out with no way back. It is called once per DIAL.
+ Token func() string
+
+ // Services routes an accepted stream by the tag in its request frame. A tag
+ // with no entry here is refused; see Tunnel.accept.
+ Services map[string]LocalService
+
+ // Seams the specs replace. They are unexported so they are not part of the
+ // package's API: a caller cannot reach them, and the internal test file can.
+ sleep func(ctx context.Context, d time.Duration) error
+ now func() time.Time
+ headerTimeout time.Duration
+}
+
+// Tunnel is a running worker tunnel: one goroutine holding one session at a
+// time, reconnecting when it dies, until Close.
+type Tunnel struct {
+ endpoint string
+ nodeID string
+ token func() string
+ services map[string]LocalService
+ dialer *websocket.Dialer
+
+ headerTimeout time.Duration
+ sleep func(ctx context.Context, d time.Duration) error
+ // now measures how long a session lasted, and nothing else. Deadlines are
+ // taken from time.Now directly: a spec that fakes this clock to exercise
+ // the backoff must not thereby move every I/O deadline in the package.
+ now func() time.Time
+
+ cancel context.CancelFunc
+ done chan struct{}
+ closeOnce sync.Once
+
+ // mu guards session, which is the tunnel's CURRENT session or nil between
+ // them. It is written by the one loop goroutine and read by whatever asks
+ // Connected, which on a running worker is an HTTP handler goroutine
+ // serving /readyz.
+ mu sync.Mutex
+ session *yamux.Session
+}
+
+// Connected reports whether the tunnel currently holds a live session.
+//
+// It is false between sessions and while the first dial is still in flight.
+// That is a statement about REACHABILITY and nothing else: a worker whose
+// tunnel is re-homing after a frontend restart reports false here and is still
+// a registered, running node. Nothing may read it as the worker being gone.
+func (t *Tunnel) Connected() bool {
+ if t == nil {
+ return false
+ }
+ t.mu.Lock()
+ sess := t.session
+ t.mu.Unlock()
+ // A session that has been closed is still the field's value until the loop
+ // clears it, and the gap between those two is exactly the window a probe
+ // must not answer 200 in.
+ return sess != nil && !sess.IsClosed()
+}
+
+// setSession publishes (or clears) the session Connected reports on.
+func (t *Tunnel) setSession(sess *yamux.Session) {
+ t.mu.Lock()
+ t.session = sess
+ t.mu.Unlock()
+}
+
+// StartTunnel dials the frontend and holds the tunnel until ctx is cancelled or
+// Close is called.
+//
+// The returned error is about this CONFIGURATION, never about the frontend. A
+// frontend that is down, that has not been upgraded, or that refuses the
+// credential is not a reason for a worker to fail to start: it retries, with
+// backoff, in the background. Failing to start on a dial would make a frontend
+// restart into a fleet-wide worker outage.
+func StartTunnel(ctx context.Context, cfg TunnelConfig) (*Tunnel, error) {
+ if cfg.NodeID == "" {
+ return nil, errors.New("starting the worker tunnel: no node id")
+ }
+ if cfg.Token == nil {
+ return nil, errors.New("starting the worker tunnel: no credential source")
+ }
+ endpoint, err := tunnelEndpoint(cfg.FrontendURL, cfg.NodeID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Copied so the tunnel's routing table cannot change under the accept loop
+ // after it has started.
+ services := make(map[string]LocalService, len(cfg.Services))
+ for tag, svc := range cfg.Services {
+ services[tag] = svc
+ }
+
+ t := &Tunnel{
+ endpoint: endpoint,
+ nodeID: cfg.NodeID,
+ token: cfg.Token,
+ services: services,
+ dialer: &websocket.Dialer{
+ HandshakeTimeout: tunnelHandshakeTimeout,
+ // A worker reaches its frontend over the public internet in the
+ // deployments this exists for, so unlike the replica-to-replica
+ // peer link this DOES honour the environment's proxy settings.
+ Proxy: http.ProxyFromEnvironment,
+ },
+ headerTimeout: cmp.Or(cfg.headerTimeout, tunnelHeaderTimeout),
+ sleep: cfg.sleep,
+ now: cfg.now,
+ done: make(chan struct{}),
+ }
+ if t.sleep == nil {
+ t.sleep = tunnelSleep
+ }
+ if t.now == nil {
+ t.now = time.Now
+ }
+
+ loopCtx, cancel := context.WithCancel(ctx)
+ t.cancel = cancel
+ go func() {
+ defer close(t.done)
+ t.run(loopCtx)
+ }()
+ return t, nil
+}
+
+// Close stops the tunnel and waits for its loop to finish. It is idempotent.
+func (t *Tunnel) Close() error {
+ t.closeOnce.Do(func() {
+ t.cancel()
+ <-t.done
+ })
+ return nil
+}
+
+// run holds one session at a time, reconnecting with bounded backoff.
+func (t *Tunnel) run(ctx context.Context) {
+ attempt := 0
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+
+ start := t.now()
+ err := t.connectAndServe(ctx)
+ if ctx.Err() != nil {
+ return
+ }
+
+ // A session that LASTED is the only evidence the frontend is healthy.
+ // See tunnelHealthyAfter for why "we connected" is not.
+ if t.now().Sub(start) >= tunnelHealthyAfter {
+ attempt = 0
+ }
+ attempt++
+
+ delay := tunnelBackoffDelay(attempt)
+ t.logSessionEnded(err, attempt, delay)
+ if err := t.sleep(ctx, delay); err != nil {
+ return
+ }
+ }
+}
+
+// connectAndServe dials, serves streams until the session ends, and leaves
+// nothing running behind it.
+func (t *Tunnel) connectAndServe(ctx context.Context) error {
+ ws, err := t.dial(ctx)
+ if err != nil {
+ return err
+ }
+
+ sess, err := yamux.Client(cluster.WebsocketConn(ws), nil, nil)
+ if err != nil {
+ _ = ws.Close()
+ return fmt.Errorf("starting the worker tunnel session: %w", err)
+ }
+ xlog.Info("Worker tunnel established", "node", t.nodeID, "frontend", t.endpoint)
+
+ // Published before the accept loop starts. What makes the answer correct
+ // once the session dies is Connected's own IsClosed check, not this clear:
+ // the clear runs only after every in-flight stream has finished, which is a
+ // wait the probe must already be answering "not ready" through. The clear
+ // is here so a dead session is not held for the life of the reconnect.
+ t.setSession(sess)
+ defer t.setSession(nil)
+
+ // Streams are served under a context of the SESSION's, not the loop's. A
+ // stream goroutine parked in a local dial would otherwise outlive the
+ // session it belongs to and hold the reconnect below behind it.
+ sessCtx, endSession := context.WithCancel(ctx)
+
+ // AcceptStream takes no context, so something else has to break it when the
+ // worker is shutting down; closing the session is that something.
+ watchdogDone := make(chan struct{})
+ go func() {
+ defer close(watchdogDone)
+ select {
+ case <-sessCtx.Done():
+ _ = sess.Close()
+ case <-sess.CloseChan():
+ }
+ }()
+
+ var streams sync.WaitGroup
+ serveErr := t.serve(sessCtx, sess, &streams)
+
+ endSession()
+ _ = sess.Close()
+ <-watchdogDone
+ // Closing the session unblocks every stream goroutine: Session.close walks
+ // its stream table calling forceClose on each (session.go:334-338), and
+ // forceClose puts both directions in halfReset and calls notifyWaiting
+ // (stream.go:371-388), which wakes a parked Read and fails a parked Write.
+ // Waiting here is what keeps a reconnect from overlapping the streams of
+ // the session it replaced.
+ streams.Wait()
+ return serveErr
+}
+
+// serve accepts streams until the session ends.
+//
+// One goroutine per stream, and an error from a stream never reaches this loop.
+// A single malformed or unroutable request must not cost this worker every
+// other request in flight on the same session.
+func (t *Tunnel) serve(ctx context.Context, sess *yamux.Session, streams *sync.WaitGroup) error {
+ for {
+ stream, err := sess.AcceptStream()
+ if err != nil {
+ return err
+ }
+ streams.Add(1)
+ go func() {
+ defer streams.Done()
+ t.handleStream(ctx, stream)
+ }()
+ }
+}
+
+// handleStream reads one stream's request frame and either splices it to a
+// local service or refuses it.
+func (t *Tunnel) handleStream(ctx context.Context, stream net.Conn) {
+ // A panic under one stream must not take the worker down, and here that is
+ // not a figure of speech: nothing supervises this goroutine, so an
+ // unrecovered panic ends the PROCESS, which ends the session and every
+ // other stream on it. Unlike the frontend's handler next door this does not
+ // re-panic, because there is no recovery middleware above it to report the
+ // panic; re-panicking would only be the crash.
+ //
+ // It covers what runs ON THIS goroutine: reading the request frame, the
+ // route lookup, and the local service's dial, which is the one of the three
+ // that runs caller-supplied code. It does NOT cover a panic inside Splice's
+ // own copy goroutines, which no recover here can reach.
+ defer func() {
+ if r := recover(); r != nil {
+ xlog.Error("Panic while serving a worker tunnel stream", "node", t.nodeID, "panic", r)
+ _ = stream.Close()
+ }
+ }()
+
+ local, ok := t.accept(ctx, stream)
+ if !ok {
+ // accept has already answered and closed the stream.
+ return
+ }
+
+ // Splice owns closing both ends from here.
+ if err := cluster.Splice(stream, local); err != nil {
+ xlog.Debug("worker tunnel stream ended with an error", "node", t.nodeID, "error", err)
+ }
+}
+
+// accept reads the request frame and resolves it to a local connection. The
+// second result is false when the stream was refused, in which case the refusal
+// has been sent and the stream closed.
+func (t *Tunnel) accept(ctx context.Context, stream net.Conn) (net.Conn, bool) {
+ // Deliberately time.Now and not t.now: this is an I/O deadline, and the
+ // clock seam exists only to measure how long a session lasted.
+ if err := stream.SetReadDeadline(time.Now().Add(t.headerTimeout)); err != nil {
+ // NotServed and not TargetUnavailable: this is a fact about the STREAM,
+ // which would not take a deadline, and no local service has been named
+ // yet, let alone dialled. Reporting it as an unreachable target would
+ // tell the frontend a backend it has not asked about is gone.
+ t.refuse(stream, fmt.Errorf("%w: arming the request deadline: %v", cluster.ErrStreamNotServed, err))
+ return nil, false
+ }
+
+ tag, target, err := cluster.ReadStreamRequest(stream)
+ if err != nil {
+ // The two causes are SEPARATED here, and merging them was a real
+ // defect. A malformed frame is the frontend's own bug and does not
+ // clear on its own, so it stays a verdict the frontend acts on. The
+ // deadline above expiring is a frame that has not ARRIVED yet, which
+ // clears the moment the link drains; on the relay path the worker's
+ // timer starts when the OWNING replica opens the stream, while the
+ // frame is written by the DIALLING replica only after the relay's
+ // acceptance has travelled back to it, so a whole peer-link round trip
+ // runs inside this window, on a link this design deliberately loads
+ // with multi-gigabyte artifacts. Reported as a malformed request it
+ // became reaping evidence, and for a long-deadline caller that is a
+ // model evicted across the fleet by nothing but congestion.
+ if reportsTimeout(err) {
+ t.refuse(stream, fmt.Errorf("%w: %v", cluster.ErrStreamNotServed, err))
+ return nil, false
+ }
+ t.refuse(stream, fmt.Errorf("%w: %v", cluster.ErrStreamRequestInvalid, err))
+ return nil, false
+ }
+
+ svc, known := t.services[tag]
+ if !known {
+ // A ROUTING fact about this worker, and it is reported as itself. A
+ // frontend that reads this knows a retry is pointless until the worker
+ // is upgraded, which is not what it should conclude from the
+ // unavailable below.
+ t.refuse(stream, fmt.Errorf("%w: %q", cluster.ErrStreamTagUnknown, tag))
+ return nil, false
+ }
+
+ // Cleared before the local dial rather than after the reply: everything
+ // past the request frame belongs to the tunnelled protocol, which brings
+ // its own deadlines, and one left armed here would abort a long inference
+ // stream in the middle.
+ if err := stream.SetReadDeadline(time.Time{}); err != nil {
+ // NotServed for the same reason as arming it: the stream is what
+ // failed, and this worker has said nothing about the target.
+ t.refuse(stream, fmt.Errorf("%w: clearing the request deadline: %v", cluster.ErrStreamNotServed, err))
+ return nil, false
+ }
+
+ local, err := svc(ctx, target)
+ if err != nil {
+ t.refuse(stream, classifyServiceFailure(err))
+ return nil, false
+ }
+
+ if err := cluster.WriteStreamAccepted(stream); err != nil {
+ // The frontend never learns the stream was accepted, so it cannot be
+ // used; closing the local connection here is what stops an accepted
+ // backend connection leaking per failed reply.
+ xlog.Debug("worker tunnel could not accept a stream", "node", t.nodeID, "error", err)
+ _ = local.Close()
+ _ = stream.Close()
+ return nil, false
+ }
+ return local, true
+}
+
+// classifyServiceFailure decides which refusal a local service's error is.
+//
+// A service that has ALREADY classified its own failure keeps that
+// classification, and that is asked of the whole vocabulary rather than of one
+// sentinel. loopbackService classifies, and the distinction is not cosmetic: a
+// target outside this worker's backend port range is a request this worker will
+// never serve, while a backend that is not listening yet is a condition that
+// clears on its own. Reporting the first as the second tells a frontend to
+// retry something that can never work; reporting the second as the first makes
+// it give up on a backend that is merely starting.
+//
+// This used to preserve ONE of the four codes, which was true to its own
+// comment for exactly as long as there was one classification worth keeping.
+// Once ErrStreamNotServed existed, a service returning the code whose entire
+// job is to say "I learned nothing" had it PROMOTED here into
+// ErrStreamTargetUnavailable, which every reap guard acts on. No in-tree
+// service produced it, which is the same "unreachable, therefore safe"
+// argument that let the request-frame merge survive a whole phase; LocalService
+// and TunnelConfig.Services are both exported, so out-of-tree is a real place.
+// cluster.IsStreamRefusal reads the vocabulary table, so a fifth code is
+// preserved here without anyone remembering to come back.
+//
+// The default is TargetUnavailable and stays that way, which is the deliberate
+// half of this function now that the frontend acts on that code. A dial to the
+// named process that came back with anything is the closest thing to evidence
+// this worker can produce, and the direction of a mis-classification decides
+// which mistake is made. A wrong reap is ACTIVE: it deletes rows and, on the
+// inference path, runs ShutdownModel on a model that is loaded and serving. A
+// wrong retention is passive, bounded to one replica slot, and clears on a
+// restart or an eviction. An allow-list of reapable causes would also fail
+// SILENTLY and PERMANENTLY when it missed one, where a deny-list that misses
+// fails loudly. So the exemptions below are a DENY-list of causes that are not
+// the target answering, not an allow-list of causes that are.
+//
+// The exempted causes, stated exactly, because an earlier version of this
+// comment said "two" and the predicate covered more than it named:
+//
+// - The context ending. Here that is the SESSION's context, cancelled while
+// stream goroutines are still running, so it means this worker's tunnel is
+// being torn down and will reconnect.
+// - This process's own I/O deadline (os.ErrDeadlineExceeded).
+// - Anything else reporting itself as a net.Error timeout, which on a DIAL
+// also covers syscall.ETIMEDOUT and syscall.EAGAIN. Those are kept
+// deliberately. EAGAIN is this worker running out of resources, which is
+// plainly not about the target. ETIMEDOUT from connect(2) IS an observation
+// about the target, but it is the observation "it did not finish the
+// handshake", which is a wedged or backlogged listener rather than an
+// absent one, and reaping an overloaded backend is the eviction this whole
+// phase exists to prevent. ECONNREFUSED, the shape of a process that is
+// genuinely gone, is not a timeout and still reaps.
+//
+// They are exempted rather than argued away as unreachable because
+// "unreachable" was the argument that made the request-frame merge look safe.
+//
+// It must never become the unknown-tag refusal either: a tag this worker serves
+// does not stop being served because one dial failed.
+func classifyServiceFailure(err error) error {
+ if cluster.IsStreamRefusal(err) {
+ return err
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || reportsTimeout(err) {
+ return fmt.Errorf("%w: %v", cluster.ErrStreamNotServed, err)
+ }
+ return fmt.Errorf("%w: %v", cluster.ErrStreamTargetUnavailable, err)
+}
+
+// reportsTimeout reports whether err says of ITSELF that it is a timeout.
+//
+// Named for what it asks rather than for where it is asked, because it is asked
+// in two places that mean different things. On a stream READ it is a deadline
+// this process armed. On a DIAL it is wider: Go's syscall.Errno.Timeout is true
+// for ETIMEDOUT and EAGAIN as well, and net.OpError passes that through. Both
+// call sites want the same ANSWER (not the target speaking, so not evidence
+// about a backend), which is why one predicate serves both; see
+// classifyServiceFailure for why the wider set is kept deliberately.
+//
+// net.Error's Timeout is asked as well as os.ErrDeadlineExceeded because the
+// two are not the same set: a yamux stream returns its own timeout value from
+// a Read whose deadline expired, and a net.OpError over a socket returns
+// os.ErrDeadlineExceeded. Missing either would put a timeout back on the
+// verdict path, which is the defect this predicate exists to keep closed.
+func reportsTimeout(err error) bool {
+ if errors.Is(err, os.ErrDeadlineExceeded) {
+ return true
+ }
+ var netErr net.Error
+ return errors.As(err, &netErr) && netErr.Timeout()
+}
+
+// refuse reports why a stream will not be served and then ENDS it.
+//
+// The close is the part that matters and it is not optional. A worker that says
+// why and leaves the stream open has parked the frontend on a request that will
+// never be answered, which reads as a slow worker rather than a refused
+// request, and a deadline on the far side cannot tell those apart. The reply is
+// what makes the refusal legible; the close is what makes it prompt.
+//
+// The reply is therefore best-effort and the close is not: a reply that could
+// not be written still gets the stream closed.
+func (t *Tunnel) refuse(stream net.Conn, reason error) {
+ if err := cluster.WriteStreamRefusal(stream, reason); err != nil {
+ xlog.Debug("worker tunnel could not report why it refused a stream", "node", t.nodeID, "error", err)
+ }
+ _ = stream.Close()
+ xlog.Debug("worker tunnel refused a stream", "node", t.nodeID, "reason", reason)
+}
+
+// dial opens the WebSocket and returns it.
+func (t *Tunnel) dial(ctx context.Context) (*websocket.Conn, error) {
+ // Read HERE, once per dial. See TunnelConfig.Token.
+ token := t.token()
+ if token == "" {
+ // Not a dial that fails with "unauthorized": this worker has no
+ // credential yet, which is a different condition from the frontend
+ // rejecting one, and an operator reading "unauthorized" would go
+ // looking for a token mismatch that does not exist.
+ return nil, errors.New("dialling the worker tunnel: this node has no tunnel credential yet")
+ }
+ header := http.Header{}
+ header.Set("Authorization", "Bearer "+token)
+
+ ws, resp, err := t.dialer.DialContext(ctx, t.endpoint, header)
+ if err != nil {
+ if resp != nil {
+ // gorilla reports every non-101 as the same ErrBadHandshake, so
+ // without the status a 401, a 403 and a 503 are one log line.
+ defer func() { _ = resp.Body.Close() }()
+ return nil, &tunnelDialError{status: resp.StatusCode, cause: err}
+ }
+ return nil, fmt.Errorf("dialling the worker tunnel: %w", err)
+ }
+ return ws, nil
+}
+
+// tunnelDialError carries the HTTP status a refused dial came back with, so the
+// four refusals the frontend can give are not logged as one.
+type tunnelDialError struct {
+ status int
+ cause error
+}
+
+func (e *tunnelDialError) Error() string {
+ return fmt.Sprintf("dialling the worker tunnel: frontend answered %d: %v", e.status, e.cause)
+}
+
+func (e *tunnelDialError) Unwrap() error { return e.cause }
+
+// logSessionEnded says why the tunnel is reconnecting, at a level that matches
+// what the operator can do about it.
+//
+// The distinctions are the point rather than decoration. "Awaiting approval"
+// and "your token is wrong" and "this frontend does not do tunnels" send an
+// operator to three different places, and a worker retries all three the same
+// way: none of them is a reason to stop, because a re-registration or an admin
+// action fixes each without restarting the worker.
+func (t *Tunnel) logSessionEnded(err error, attempt int, delay time.Duration) {
+ if err == nil {
+ xlog.Info("Worker tunnel closed, reconnecting", "node", t.nodeID, "attempt", attempt, "retry_in", delay)
+ return
+ }
+
+ var dialErr *tunnelDialError
+ if errors.As(err, &dialErr) {
+ switch dialErr.status {
+ case http.StatusUnauthorized:
+ // Named causes, because this worker cannot recover from either on
+ // its own and the two need different actions. It has no inbound
+ // listener and no advertised address, so a tunnel it cannot open is
+ // a worker nothing can reach: this is an outage, not a warning about
+ // a degraded path.
+ xlog.Warn("Frontend rejected this worker's tunnel credential, so nothing can reach this worker; "+
+ "either another worker registered under this node name and rotated the credential (check LOCALAI_NODE_NAME is unique), "+
+ "or the frontend's record of this node was replaced. Restarting this worker re-registers and mints a fresh credential",
+ "node", t.nodeID, "retry_in", delay)
+ case http.StatusForbidden:
+ xlog.Info("Worker tunnel refused: this node is awaiting admin approval",
+ "node", t.nodeID, "retry_in", delay)
+ case http.StatusNotFound:
+ xlog.Debug("frontend does not serve worker tunnels, so it predates them",
+ "node", t.nodeID, "retry_in", delay)
+ case http.StatusServiceUnavailable:
+ xlog.Debug("frontend is not running in distributed mode, so it holds no worker tunnels",
+ "node", t.nodeID, "retry_in", delay)
+ default:
+ xlog.Warn("Worker tunnel dial refused", "node", t.nodeID, "status", dialErr.status,
+ "attempt", attempt, "retry_in", delay, "error", err)
+ }
+ return
+ }
+ xlog.Warn("Worker tunnel ended, reconnecting", "node", t.nodeID, "attempt", attempt, "retry_in", delay, "error", err)
+}
+
+// tunnelBackoffDelay returns how long to wait before reconnect attempt n.
+//
+// Equal jitter: half the delay is fixed and half is drawn. Full jitter, which
+// draws over the whole interval, can produce a near-zero wait, and a worker
+// that can draw a near-zero wait can spin; keeping a floor means no single
+// worker ever does, while the drawn half is what stops a fleet that all lost
+// the same replica from resynchronising onto the same instant.
+func tunnelBackoffDelay(attempt int) time.Duration {
+ if attempt < 1 {
+ attempt = 1
+ }
+ d := tunnelBackoffMax
+ // The shift is guarded twice. The bound on attempt keeps the shift itself
+ // defined, and the positivity check catches the overflow that would
+ // otherwise turn a long outage into a NEGATIVE delay, which is a tight loop
+ // wearing a backoff's costume.
+ if attempt <= 40 {
+ if scaled := tunnelBackoffBase << (attempt - 1); scaled > 0 && scaled < tunnelBackoffMax {
+ d = scaled
+ }
+ }
+ return d/2 + time.Duration(rand.Int64N(int64(d/2)+1))
+}
+
+// tunnelSleep waits for d, or returns early when ctx is cancelled.
+func tunnelSleep(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
+// tunnelEndpoint turns the frontend URL a worker registers against into the
+// WebSocket URL it dials its tunnel on.
+func tunnelEndpoint(frontendURL, nodeID string) (string, error) {
+ if frontendURL == "" {
+ return "", errors.New("starting the worker tunnel: no frontend URL")
+ }
+ u, err := url.Parse(frontendURL)
+ if err != nil {
+ return "", fmt.Errorf("starting the worker tunnel: parsing frontend URL %q: %w", frontendURL, err)
+ }
+ switch u.Scheme {
+ case "http", "ws":
+ u.Scheme = "ws"
+ case "https", "wss":
+ u.Scheme = "wss"
+ default:
+ return "", fmt.Errorf("starting the worker tunnel: frontend URL %q has scheme %q, want http or https", frontendURL, u.Scheme)
+ }
+ if u.Host == "" {
+ return "", fmt.Errorf("starting the worker tunnel: frontend URL %q has no host", frontendURL)
+ }
+ // Appended rather than assigned, so a frontend served under a path prefix
+ // keeps it. Registration builds its URLs the same way.
+ u.Path = strings.TrimRight(u.Path, "/") + cluster.ConnectPath
+ u.RawQuery = url.Values{"id": []string{nodeID}}.Encode()
+ return u.String(), nil
+}
+
+// loopbackService routes a tagged stream to a process listening on this
+// worker's own loopback interface.
+//
+// The HOST the frontend names is discarded and only the port is used, which is
+// deliberate and is the security property this function exists for. A tunnel
+// terminates inside the worker process, so a stream arriving on it can reach
+// anything the worker can reach; without this, whoever holds the frontend end
+// could make every worker in the fleet dial arbitrary hosts on its private
+// network, turning the tunnel into a proxy into the worker's LAN. Discarding
+// the host reduces the reachable set to this machine.
+//
+// It also happens to be what makes the tunnel work BEFORE the workers stop
+// advertising themselves: today the frontend names the address the worker
+// registered, which is a routable one, and after that change it will name a
+// loopback one. Both resolve to the same place here.
+//
+// The port range is the one the worker's own port allocator hands to backend
+// processes, so a stream cannot be pointed at some unrelated service that
+// happens to be listening on this host. It is only as tight as the allocator's
+// range, which by default runs to 65535; a deployment that wants it narrow sets
+// LOCALAI_GRPC_MAX_PORT, which narrows both at once.
+//
+// Note the SHAPE, not only the checks. Nothing derived from the wire reaches
+// the dialler: the address is built from the loopbackHost constant and from
+// strconv.Itoa of an int this function validated, so `target` itself has no
+// path to DialContext at all. Relaxing this into an arbitrary-host dialler
+// therefore takes ADDING a data flow rather than deleting a check, which is the
+// difference between a guard and a property. It has specs either way; the shape
+// is what stops a plausible refactor from quietly restoring the hole.
+func loopbackService(minPort, maxPort int) LocalService {
+ return func(ctx context.Context, target string) (net.Conn, error) {
+ _, portStr, err := net.SplitHostPort(target)
+ if err != nil {
+ return nil, fmt.Errorf("%w: routing a tunnel stream: %q is not a host:port: %v",
+ cluster.ErrStreamRequestInvalid, target, err)
+ }
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return nil, fmt.Errorf("%w: routing a tunnel stream: %q has no numeric port: %v",
+ cluster.ErrStreamRequestInvalid, target, err)
+ }
+ if port < minPort || port > maxPort {
+ // Invalid rather than unavailable: no retry can bring a port
+ // outside this worker's own allocator range into it.
+ return nil, fmt.Errorf("%w: routing a tunnel stream: port %d is outside this worker's backend range [%d, %d]",
+ cluster.ErrStreamRequestInvalid, port, minPort, maxPort)
+ }
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", net.JoinHostPort(loopbackHost, strconv.Itoa(port)))
+ }
+}
+
+// loopbackHost is the host every stream the FRONTEND CAN STEER is dialled on.
+//
+// It is a constant so that "a stream cannot choose where the worker dials" is a
+// fact about the code rather than a claim about its inputs: the grpc tag builds
+// its address from this and a port it validated, and nothing derived from the
+// wire reaches the dialler.
+//
+// It is NOT the only host this file ever dials, and the difference is worth
+// stating exactly rather than summarising, because the whole argument about
+// what a stream can reach rests on knowing which hosts are reachable, and an
+// overstatement here is what would let a future reader conclude the constant
+// alone is doing the work.
+//
+// fixedService dials whatever address it was constructed with. Run constructs
+// it from this worker's own LOCALAI_HTTP_ADDR, which an operator may set to a
+// routable address; loopbackAddr only rewrites a WILDCARD bind, and leaves an
+// explicit host alone on purpose, because a server bound to one address is not
+// reachable on another. So the http tag can dial a non-loopback host. That host
+// is one the OPERATOR configured for this worker's own server, never one a
+// stream names: fixedService ignores its target entirely. The property the
+// design needs is that the frontend cannot steer the dial, and that holds for
+// both tags.
+const loopbackHost = "127.0.0.1"
+
+// tunnelServices builds the routing table the worker installs on its tunnel.
+//
+// It exists as its own function so the table can be specced. The table is the
+// security boundary of this whole feature, and building it inline in Run left
+// it reachable only by starting a worker, which meant it was covered by nothing
+// and an arbitrary-host regression passed the entire suite.
+func tunnelServices(cfg *Config, httpBindAddr string) map[string]LocalService {
+ basePort := cfg.effectiveBasePort()
+ return map[string]LocalService{
+ // The frontend names a backend process by its port; the worker decides
+ // that only its own loopback, and only within its own backend port
+ // range, is reachable through it.
+ cluster.StreamTagGRPC: loopbackService(basePort, cfg.effectiveMaxPort(basePort)),
+ cluster.StreamTagHTTP: fixedService(loopbackAddr(httpBindAddr)),
+ }
+}
+
+// fixedService routes a tagged stream to one address on this worker, ignoring
+// whatever the frontend named.
+//
+// There is exactly one HTTP server per worker and only the worker knows where
+// it bound, so the frontend has nothing useful to say about the target and is
+// not given the chance to say it.
+func fixedService(addr string) LocalService {
+ return func(ctx context.Context, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", addr)
+ }
+}
+
+// loopbackAddr rewrites a bind address into one that reaches the same listener
+// from inside this process.
+//
+// A server bound to 0.0.0.0 is reachable on loopback, but dialling 0.0.0.0 is
+// only accidentally equivalent to dialling localhost and is not on every
+// platform, so the wildcard is replaced rather than dialled.
+func loopbackAddr(bindAddr string) string {
+ host, port, err := net.SplitHostPort(bindAddr)
+ if err != nil {
+ return bindAddr
+ }
+ if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
+ return net.JoinHostPort(loopbackHost, port)
+ }
+ return bindAddr
+}
diff --git a/core/services/worker/tunnel_test.go b/core/services/worker/tunnel_test.go
new file mode 100644
index 000000000000..0930b6f72219
--- /dev/null
+++ b/core/services/worker/tunnel_test.go
@@ -0,0 +1,1126 @@
+package worker
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "math/rand/v2"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/libp2p/go-yamux/v5"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/nodes"
+)
+
+// awaitErr runs fn on its own goroutine and reports its result on a channel.
+//
+// Every blocking read in this file goes through it, and that is the single most
+// load-bearing decision in the whole suite. The obvious way to assert "the
+// worker refused this stream promptly" is to arm a read deadline and expect an
+// error, and phase 1 shipped exactly that in three places: it held in none,
+// because a stream the worker never answers AT ALL satisfies a deadline
+// assertion just as well as one it refused. Reading with NO deadline, on
+// another goroutine, and asserting the channel delivers, inverts that: a parked
+// stream delivers nothing and the Eventually fails.
+func awaitErr(fn func() error) <-chan error {
+ ch := make(chan error, 1)
+ go func() { ch <- fn() }()
+ return ch
+}
+
+// tunnelDial is what the fake frontend saw on one incoming dial.
+type tunnelDial struct {
+ token string
+ nodeID string
+}
+
+// fakeFrontend is the far side of the tunnel: it speaks the real WebSocket
+// upgrade and the real yamux server handshake, so these specs exercise the
+// wire, not a mock of it. It is deliberately NOT core/http's handler; that one
+// needs a database, and what is under test here is the client.
+type fakeFrontend struct {
+ srv *httptest.Server
+ sessions chan *yamux.Session
+ dials chan tunnelDial
+
+ // closeAtOnce makes every accepted session die immediately, which is what a
+ // frontend replica going down during a rolling restart looks like from the
+ // worker.
+ closeAtOnce bool
+}
+
+func newFakeFrontend(closeAtOnce bool) *fakeFrontend {
+ f := &fakeFrontend{
+ sessions: make(chan *yamux.Session, 64),
+ dials: make(chan tunnelDial, 256),
+ closeAtOnce: closeAtOnce,
+ }
+ upgrader := websocket.Upgrader{}
+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != cluster.ConnectPath {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ select {
+ case f.dials <- tunnelDial{
+ token: strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "),
+ nodeID: r.URL.Query().Get("id"),
+ }:
+ default:
+ }
+
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ sess, err := yamux.Server(cluster.WebsocketConn(ws), nil, nil)
+ if err != nil {
+ _ = ws.Close()
+ return
+ }
+ if f.closeAtOnce {
+ _ = sess.Close()
+ return
+ }
+ select {
+ case f.sessions <- sess:
+ default:
+ _ = sess.Close()
+ }
+ }))
+ return f
+}
+
+func (f *fakeFrontend) close() {
+ for {
+ select {
+ case sess := <-f.sessions:
+ _ = sess.Close()
+ default:
+ f.srv.Close()
+ return
+ }
+ }
+}
+
+// echoListener is a stand-in for a backend gRPC process on the worker: a local
+// TCP listener that reads and writes back.
+func echoListener() net.Listener {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).ToNot(HaveOccurred())
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() {
+ defer func() { _ = conn.Close() }()
+ _, _ = io.Copy(conn, conn)
+ }()
+ }
+ }()
+ return ln
+}
+
+// dialLocalTCP is the simplest possible LocalService: connect to whatever the
+// frontend named.
+func dialLocalTCP(ctx context.Context, target string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", target)
+}
+
+var _ = Describe("Worker tunnel client", func() {
+ var (
+ ctx context.Context
+ cancel context.CancelFunc
+ frontend *fakeFrontend
+ tunnel *Tunnel
+ )
+
+ BeforeEach(func() {
+ ctx, cancel = context.WithCancel(context.Background())
+ })
+
+ AfterEach(func() {
+ if tunnel != nil {
+ Expect(tunnel.Close()).To(Succeed())
+ tunnel = nil
+ }
+ cancel()
+ if frontend != nil {
+ frontend.close()
+ frontend = nil
+ }
+ })
+
+ // start brings up the client against the fake frontend already created.
+ start := func(mutate func(*TunnelConfig)) {
+ cfg := TunnelConfig{
+ FrontendURL: frontend.srv.URL,
+ NodeID: "node-1",
+ Token: func() string { return "tunnel-secret" },
+ Services: map[string]LocalService{},
+ }
+ if mutate != nil {
+ mutate(&cfg)
+ }
+ var err error
+ tunnel, err = StartTunnel(ctx, cfg)
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // session waits for the frontend to have accepted the worker's dial.
+ session := func() *yamux.Session {
+ var sess *yamux.Session
+ EventuallyWithOffset(1, frontend.sessions, "10s").Should(Receive(&sess))
+ return sess
+ }
+
+ Describe("carrying a tagged stream to a local service", func() {
+ It("routes a stream tagged for gRPC to the local address it names", func() {
+ ln := echoListener()
+ DeferCleanup(func() { _ = ln.Close() })
+
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, ln.Addr().String())).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ Eventually(reply, "10s").Should(Receive(BeNil()))
+
+ _, err = stream.Write([]byte("ping"))
+ Expect(err).ToNot(HaveOccurred())
+
+ buf := make([]byte, 4)
+ read := awaitErr(func() error {
+ _, err := io.ReadFull(stream, buf)
+ return err
+ })
+ Eventually(read, "10s").Should(Receive(BeNil()))
+ Expect(string(buf)).To(Equal("ping"))
+ })
+
+ It("routes through the worker's OWN table, ignoring the host the frontend names", func() {
+ // Every other spec in this file installs dialLocalTCP, which dials
+ // whatever it is handed. This one installs tunnelServices, the
+ // table Run installs, so the wire path is exercised against the
+ // real routing rules at least once.
+ backend := echoListenerOn("127.0.0.1:0")
+ DeferCleanup(func() { _ = backend.Close() })
+ port := portOf(backend)
+
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services = tunnelServices(&Config{
+ ServeAddr: fmt.Sprintf("0.0.0.0:%d", port),
+ GRPCMaxPort: port,
+ }, "0.0.0.0:1")
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ // A host that is not this machine, and a port that is.
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC,
+ fmt.Sprintf("attacker.invalid:%d", port))).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ Eventually(reply, "10s").Should(Receive(BeNil()))
+
+ _, err = stream.Write([]byte("loopback"))
+ Expect(err).ToNot(HaveOccurred())
+ buf := make([]byte, len("loopback"))
+ read := awaitErr(func() error {
+ _, err := io.ReadFull(stream, buf)
+ return err
+ })
+ Eventually(read, "10s").Should(Receive(BeNil()))
+ Expect(string(buf)).To(Equal("loopback"))
+ })
+
+ It("refuses a port outside its range as a bad request, over the wire", func() {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services = tunnelServices(&Config{
+ ServeAddr: "0.0.0.0:50051",
+ GRPCMaxPort: 50051,
+ }, "0.0.0.0:50050")
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, "127.0.0.1:22")).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+ // Three refusals, three meanings. A frontend retries unavailable
+ // and gives up on this one.
+ Expect(got).To(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTagUnknown))
+ })
+ })
+
+ Describe("refusing a stream it cannot serve", func() {
+ // The refusal specs all read with NO deadline, on another goroutine.
+ // See awaitErr: a deadline would be satisfied by a stream that was
+ // merely parked, which is the exact defect this phase inherited.
+
+ It("refuses an unknown tag promptly, and the stream ENDS rather than parking", func() {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, "no-such-tag", "")).To(Succeed())
+
+ // Two facts, in order, on one goroutine: the worker SAID why, and
+ // then the stream ended. A worker that only says why and leaves the
+ // stream open never sends on this channel, so the Eventually below
+ // fails rather than passing on a deadline.
+ type outcome struct{ reply, end error }
+ done := make(chan outcome, 1)
+ go func() {
+ var got outcome
+ got.reply = cluster.ReadStreamReply(stream)
+ _, got.end = stream.Read(make([]byte, 1))
+ done <- got
+ }()
+
+ var got outcome
+ Eventually(done, "10s").Should(Receive(&got))
+ Expect(got.reply).To(MatchError(cluster.ErrStreamTagUnknown))
+ Expect(got.end).To(MatchError(io.EOF))
+ })
+
+ It("reports a local service it could not reach as unavailable, not as an unknown tag", func() {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = func(context.Context, string) (net.Conn, error) {
+ return nil, fmt.Errorf("connection refused")
+ }
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, "127.0.0.1:1")).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+ // Distinct conditions must not be reported as each other: a caller
+ // gives up on an unknown tag and retries an unavailable target.
+ Expect(got).To(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTagUnknown))
+ })
+
+ It("ends a stream whose request never arrives instead of holding it open", func() {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ c.headerTimeout = 50 * time.Millisecond
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ // Nothing is written. A worker that waits forever for a request it
+ // will never get holds a goroutine and a stream slot per dial.
+ ended := awaitErr(func() error {
+ _, err := io.Copy(io.Discard, stream)
+ return err
+ })
+ Eventually(ended, "10s").Should(Receive(BeNil()))
+ })
+
+ It("says it learned NOTHING when the request frame never arrived in time", func() {
+ // The producer side of the phase's worst self-inflicted defect.
+ //
+ // This refusal used to be ErrStreamRequestInvalid, merged with a
+ // genuinely malformed frame on the grounds that both are "this
+ // stream never told me what it wanted". That was safe only while
+ // the frontend treated every refusal as "no route". Once
+ // nodes.unroutable started exempting worker answers so a crashed
+ // backend could be reaped, this became reaping evidence for a frame
+ // that had merely not ARRIVED yet.
+ //
+ // It is reachable: on the relay path the worker's header timer
+ // starts when the OWNING replica opens the stream, while the frame
+ // is written by the DIALLING replica only after the relay
+ // acceptance travels back, so a peer-link round trip runs inside
+ // this window on a link that also carries multi-gigabyte artifacts.
+ // For a long-deadline caller the endpoint is
+ // ConnectionEvictingClient, which stops the model across the fleet.
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ c.headerTimeout = 50 * time.Millisecond
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ // Nothing is written, so only the header timer can end this.
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+
+ Expect(got).To(MatchError(cluster.ErrStreamNotServed))
+ // The three assertions that make this bite. Each of the other
+ // sentinels is evidence the frontend acts on, and the predicate is
+ // the single place the two lists are kept identical.
+ Expect(got).ToNot(MatchError(cluster.ErrStreamRequestInvalid),
+ "a frame that arrived late is not a malformed frame, and this one reaps")
+ Expect(got).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeFalse(),
+ "a timeout must reach a reap guard as no-route, never as the worker's verdict")
+ })
+
+ It("still calls a MALFORMED request frame malformed, which is a verdict", func() {
+ // The other direction. Separating the timeout out must not turn the
+ // verdict off: a frontend that writes a frame this worker cannot
+ // parse has a bug that no retry fixes, and the refusal has to keep
+ // saying so.
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ c.headerTimeout = time.Minute
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ // A frame whose declared length exceeds what the reader will take,
+ // so the failure is the frame's shape and not the clock.
+ Expect(binary.Write(stream, binary.BigEndian, uint16(60000))).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+
+ Expect(got).To(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(got).ToNot(MatchError(cluster.ErrStreamNotServed))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeTrue())
+ })
+
+ It("says it learned nothing when the local dial ended on the session going away", func() {
+ // classifyServiceFailure's deny-list. The default there is
+ // TargetUnavailable and stays that way, because a mis-classified
+ // dial failure must fall towards "reapable" rather than towards a
+ // row nothing can ever delete. What is exempted is the pair of
+ // causes that are provably not the target answering: this worker's
+ // own session context ending, and its own I/O deadline firing.
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = func(ctx context.Context, _ string) (net.Conn, error) {
+ return nil, fmt.Errorf("dialing the backend: %w", context.Canceled)
+ }
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, "127.0.0.1:41000")).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+ Expect(got).To(MatchError(cluster.ErrStreamNotServed))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeFalse())
+ })
+
+ DescribeTable("keeps a classification the local service already made",
+ // The latent instance of the same shape, found by the gate rather
+ // than by anything reaching it. This function preserved exactly ONE
+ // of the four codes, which was faithful to its own comment for as
+ // long as there was one worth keeping. Once ErrStreamNotServed
+ // existed, a service returning the code whose whole job is to say
+ // "I learned nothing" had it PROMOTED to ErrStreamTargetUnavailable,
+ // which every reap guard acts on.
+ //
+ // No in-tree service produced it, which is the "unreachable,
+ // therefore safe" argument that let the request-frame merge survive
+ // a whole phase. LocalService and TunnelConfig.Services are both
+ // exported, so out-of-tree is a real place, and the same standard
+ // applies.
+ func(classified error, wantEvidence bool) {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = func(context.Context, string) (net.Conn, error) {
+ return nil, fmt.Errorf("the service decided for itself: %w", classified)
+ }
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, "127.0.0.1:41000")).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+ Expect(got).To(MatchError(classified),
+ "re-classifying overwrites a decision made closer to the failure")
+ Expect(cluster.IsWorkerAnswer(got)).To(Equal(wantEvidence))
+ },
+ // The one the promotion broke: not evidence before, evidence after.
+ Entry("I learned nothing", cluster.ErrStreamNotServed, false),
+ // Promoted too. Both sides reap, so it cost nothing, which is
+ // exactly why nothing caught it.
+ Entry("I do not serve that tag", cluster.ErrStreamTagUnknown, true),
+ Entry("that request was malformed", cluster.ErrStreamRequestInvalid, true),
+ Entry("I could not reach the target", cluster.ErrStreamTargetUnavailable, true),
+ )
+
+ It("still reports a refused local dial as an unavailable target, which reaps", func() {
+ // The other direction for the deny-list: the ordinary shape of a
+ // crashed backend must keep producing the code the reap guards act
+ // on, or the ghost rows come back.
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = func(context.Context, string) (net.Conn, error) {
+ return nil, fmt.Errorf("dial tcp 127.0.0.1:41000: connect: %w", syscall.ECONNREFUSED)
+ }
+ })
+
+ stream, err := session().OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(stream, cluster.StreamTagGRPC, "127.0.0.1:41000")).To(Succeed())
+
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(stream) })
+ var got error
+ Eventually(reply, "10s").Should(Receive(&got))
+ Expect(got).To(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(cluster.IsWorkerAnswer(got)).To(BeTrue())
+ })
+ })
+
+ Describe("surviving a bad stream", func() {
+ It("keeps serving the session after one stream it could not read", func() {
+ ln := echoListener()
+ DeferCleanup(func() { _ = ln.Close() })
+
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ })
+ sess := session()
+
+ // A frame that declares far more than it sends, then hangs up. The
+ // worker cannot parse it and must not take the session down with it.
+ bad, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ var hdr [2]byte
+ binary.BigEndian.PutUint16(hdr[:], 900)
+ _, err = bad.Write(append(hdr[:], []byte("gr")...))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bad.CloseWrite()).To(Succeed())
+ badEnded := awaitErr(func() error {
+ _, err := io.Copy(io.Discard, bad)
+ return err
+ })
+ Eventually(badEnded, "10s").Should(Receive(BeNil()))
+
+ // Same session, a stream the worker can serve.
+ good, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(good, cluster.StreamTagGRPC, ln.Addr().String())).To(Succeed())
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(good) })
+ Eventually(reply, "10s").Should(Receive(BeNil()))
+
+ _, err = good.Write([]byte("still here"))
+ Expect(err).ToNot(HaveOccurred())
+ buf := make([]byte, len("still here"))
+ read := awaitErr(func() error {
+ _, err := io.ReadFull(good, buf)
+ return err
+ })
+ Eventually(read, "10s").Should(Receive(BeNil()))
+ Expect(string(buf)).To(Equal("still here"))
+ })
+ It("serves streams concurrently, so one live stream does not block the next", func() {
+ ln := echoListener()
+ DeferCleanup(func() { _ = ln.Close() })
+
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ })
+ sess := session()
+
+ // The first stream is accepted and then left open with nothing
+ // flowing, which is what an idle inference stream or a paused file
+ // transfer looks like. Serving streams from the accept loop rather
+ // than a goroutine each would park every later request behind it.
+ first, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(first, cluster.StreamTagGRPC, ln.Addr().String())).To(Succeed())
+ firstReply := awaitErr(func() error { return cluster.ReadStreamReply(first) })
+ Eventually(firstReply, "10s").Should(Receive(BeNil()))
+
+ second, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(second, cluster.StreamTagGRPC, ln.Addr().String())).To(Succeed())
+ secondReply := awaitErr(func() error { return cluster.ReadStreamReply(second) })
+ Eventually(secondReply, "10s").Should(Receive(BeNil()))
+ })
+
+ It("keeps the session after a local service panics", func() {
+ ln := echoListener()
+ DeferCleanup(func() { _ = ln.Close() })
+
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ c.Services["explodes"] = func(context.Context, string) (net.Conn, error) {
+ panic("a local service blew up")
+ }
+ c.Services[cluster.StreamTagGRPC] = dialLocalTCP
+ })
+ sess := session()
+
+ // Nothing supervises a stream goroutine, so an unrecovered panic
+ // here ends the process, which is the loudest possible way to kill
+ // the session.
+ boom, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(boom, "explodes", "")).To(Succeed())
+ boomEnded := awaitErr(func() error {
+ _, err := io.Copy(io.Discard, boom)
+ return err
+ })
+ Eventually(boomEnded, "10s").Should(Receive(BeNil()))
+
+ good, err := sess.OpenStream(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(cluster.WriteStreamRequest(good, cluster.StreamTagGRPC, ln.Addr().String())).To(Succeed())
+ reply := awaitErr(func() error { return cluster.ReadStreamReply(good) })
+ Eventually(reply, "10s").Should(Receive(BeNil()))
+ })
+ })
+
+ Describe("reconnecting", func() {
+ It("backs off exponentially between reconnects, bounded and never tight", func() {
+ frontend = newFakeFrontend(true) // every session dies at once
+
+ delays := make(chan time.Duration, 64)
+ start(func(c *TunnelConfig) {
+ c.sleep = func(ctx context.Context, d time.Duration) error {
+ select {
+ case delays <- d:
+ default:
+ }
+ return ctx.Err()
+ }
+ })
+
+ observed := make([]time.Duration, 0, 10)
+ for i := 0; i < 10; i++ {
+ var d time.Duration
+ Eventually(delays, "20s").Should(Receive(&d), "expected reconnect attempt %d", i+1)
+ observed = append(observed, d)
+ }
+
+ for i, d := range observed {
+ // Never a tight loop: a worker that reconnect-storms a frontend
+ // during a rolling restart is a denial of service against the
+ // control plane.
+ Expect(d).To(BeNumerically(">", 0), "delay %d was not positive", i+1)
+ // Bounded: without a ceiling a worker that misses a rolling
+ // restart backs off into hours and never comes back.
+ Expect(d).To(BeNumerically("<=", tunnelBackoffMax), "delay %d exceeded the ceiling", i+1)
+ }
+ // And it actually grows. The jitter has a floor of half the
+ // unjittered delay, so the fourth attempt is at least 4x the base
+ // however the dice fall.
+ Expect(observed[3]).To(BeNumerically(">=", 4*tunnelBackoffBase))
+ })
+
+ It("keeps backing off after a session that died at once", func() {
+ frontend = newFakeFrontend(true)
+
+ delays := make(chan time.Duration, 64)
+ start(func(c *TunnelConfig) {
+ c.sleep = func(ctx context.Context, d time.Duration) error {
+ select {
+ case delays <- d:
+ default:
+ }
+ return ctx.Err()
+ }
+ })
+
+ var last time.Duration
+ for i := 0; i < 6; i++ {
+ Eventually(delays, "20s").Should(Receive(&last))
+ }
+ // A session that came up and died immediately is not evidence the
+ // frontend is healthy, so the delay must NOT be back at the floor.
+ Expect(last).To(BeNumerically(">", tunnelBackoffBase))
+ })
+
+ It("returns to its shortest delay after a session that lasted", func() {
+ frontend = newFakeFrontend(true)
+
+ // A clock that jumps a minute on every reading. The loop reads it
+ // once when a session comes up and once when it ends, so every
+ // session looks like it lasted a minute, which is longer than the
+ // threshold below which a session is not counted as healthy.
+ var ticks atomic.Int64
+ base := time.Now()
+
+ delays := make(chan time.Duration, 64)
+ start(func(c *TunnelConfig) {
+ c.now = func() time.Time {
+ return base.Add(time.Duration(ticks.Add(1)) * time.Minute)
+ }
+ c.sleep = func(ctx context.Context, d time.Duration) error {
+ select {
+ case delays <- d:
+ default:
+ }
+ return ctx.Err()
+ }
+ })
+
+ for i := 0; i < 6; i++ {
+ var d time.Duration
+ Eventually(delays, "20s").Should(Receive(&d), "expected reconnect attempt %d", i+1)
+ Expect(d).To(BeNumerically("<=", tunnelBackoffBase),
+ "delay %d did not return to the floor after a session that lasted", i+1)
+ }
+ })
+
+ It("presents the credential current at DIAL time, not the one it started with", func() {
+ frontend = newFakeFrontend(true)
+
+ var issued atomic.Int64
+ start(func(c *TunnelConfig) {
+ c.Token = func() string { return fmt.Sprintf("token-%d", issued.Add(1)) }
+ c.sleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() }
+ })
+
+ // Nothing survives a reconnect: the new owner replica has no record
+ // of the old session, and the worker's own credential may have been
+ // rotated by a re-registration in between. A client that captured
+ // its token once locks itself out on the first rotation.
+ var first, second tunnelDial
+ Eventually(frontend.dials, "20s").Should(Receive(&first))
+ Eventually(frontend.dials, "20s").Should(Receive(&second))
+ Expect(first.token).To(Equal("token-1"))
+ Expect(second.token).To(Equal("token-2"))
+ Expect(first.nodeID).To(Equal("node-1"))
+ Expect(second.nodeID).To(Equal("node-1"))
+ })
+ })
+
+ // Connected is what the worker's /readyz reports, so these specs run
+ // against the real WebSocket and the real yamux handshake rather than a
+ // flag someone sets. A double that never touches the transport cannot go
+ // false the way a dropped session does.
+ Describe("reporting whether it holds a session", func() {
+ It("reports connected once the frontend has accepted its dial", func() {
+ frontend = newFakeFrontend(false)
+ start(nil)
+ sess := session()
+ Expect(sess).ToNot(BeNil())
+ Eventually(tunnel.Connected, "10s").Should(BeTrue())
+ })
+
+ It("reports disconnected once the session is gone", func() {
+ frontend = newFakeFrontend(false)
+ start(func(c *TunnelConfig) {
+ // Park the reconnect so the spec observes the gap between
+ // sessions rather than racing the next dial.
+ c.sleep = func(ctx context.Context, _ time.Duration) error {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ })
+ sess := session()
+ Eventually(tunnel.Connected, "10s").Should(BeTrue())
+
+ Expect(sess.Close()).To(Succeed())
+ Eventually(tunnel.Connected, "10s").Should(BeFalse())
+ })
+
+ It("reports disconnected before the first dial has landed", func() {
+ // The frontend is never started, so nothing can accept. A worker
+ // that answered ready here would announce itself the moment its
+ // process came up, which is exactly the 200-on-a-useless-port that
+ // the readiness probe exists to stop.
+ frontend = newFakeFrontend(false)
+ frontend.srv.Close()
+ start(func(c *TunnelConfig) {
+ c.sleep = func(ctx context.Context, _ time.Duration) error {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ })
+ Consistently(tunnel.Connected, "500ms", "50ms").Should(BeFalse())
+ })
+
+ It("reports disconnected while it still holds a session that has been closed", func() {
+ // The window this closes is real and is not the same as the one
+ // the loop's own clear closes. When a session dies, the loop waits
+ // for every stream already in flight before it returns and clears
+ // the field, and for the whole of that wait the tunnel still HOLDS
+ // a session that can carry nothing new. Reading only "the field is
+ // set" would answer ready for the length of that wait.
+ c1, c2 := net.Pipe()
+ DeferCleanup(func() { _ = c1.Close(); _ = c2.Close() })
+ sess, err := yamux.Client(c1, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+
+ held := &Tunnel{}
+ held.setSession(sess)
+ Expect(held.Connected()).To(BeTrue())
+
+ Expect(sess.Close()).To(Succeed())
+ Expect(held.Connected()).To(BeFalse())
+ })
+
+ It("reports disconnected on a nil tunnel rather than panicking", func() {
+ var absent *Tunnel
+ Expect(absent.Connected()).To(BeFalse())
+ })
+ })
+
+ // The worker's /readyz is armed on the tunnel, and the arming is the kind
+ // of line whose loss has no symptom: WorkerReadiness fails open, so a
+ // worker that never armed it answers 200 forever with no session. These
+ // specs are what makes that line's absence visible.
+ Describe("arming the readiness gate", func() {
+ It("answers not ready until the frontend has accepted the dial", func() {
+ frontend = newFakeFrontend(false)
+ frontend.srv.Close()
+ readiness := &nodes.WorkerReadiness{}
+ var err error
+ tunnel, err = startTunnelAndArmReadiness(ctx, readiness, TunnelConfig{
+ FrontendURL: frontend.srv.URL,
+ NodeID: "node-1",
+ Token: func() string { return "tunnel-secret" },
+ sleep: func(ctx context.Context, _ time.Duration) error {
+ <-ctx.Done()
+ return ctx.Err()
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(readiness.Check, "500ms", "50ms").Should(MatchError(nodes.ErrTunnelDisconnected))
+ })
+
+ It("answers ready once the tunnel holds a session, and not ready again once it goes", func() {
+ // Both halves, in one spec, on purpose. WorkerReadiness fails open
+ // when no probe is installed, so "ready once connected" passes just
+ // as well against a gate that was never armed at all. Only the
+ // return to ErrTunnelDisconnected tells those two apart.
+ frontend = newFakeFrontend(false)
+ readiness := &nodes.WorkerReadiness{}
+ var err error
+ tunnel, err = startTunnelAndArmReadiness(ctx, readiness, TunnelConfig{
+ FrontendURL: frontend.srv.URL,
+ NodeID: "node-1",
+ Token: func() string { return "tunnel-secret" },
+ sleep: func(ctx context.Context, _ time.Duration) error {
+ <-ctx.Done()
+ return ctx.Err()
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ sess := session()
+ Eventually(readiness.Check, "10s").Should(Succeed())
+
+ Expect(sess.Close()).To(Succeed())
+ Eventually(readiness.Check, "10s").Should(MatchError(nodes.ErrTunnelDisconnected))
+ })
+
+ It("leaves the gate alone when the tunnel cannot start at all", func() {
+ // A configuration refusal is not a readiness answer: Run turns it
+ // into a fatal error, and a gate armed on a tunnel that does not
+ // exist would report on nothing.
+ readiness := &nodes.WorkerReadiness{}
+ t, err := startTunnelAndArmReadiness(ctx, readiness, TunnelConfig{
+ FrontendURL: "http://frontend:8080",
+ Token: func() string { return "tunnel-secret" },
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(t).To(BeNil())
+ Expect(readiness.Check()).To(Succeed())
+ })
+ })
+})
+
+// echoListenerOn is echoListener bound to a specific address, so a spec can put
+// a listener somewhere the worker must NOT reach.
+func echoListenerOn(addr string) net.Listener {
+ ln, err := net.Listen("tcp", addr)
+ Expect(err).ToNot(HaveOccurred())
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() {
+ defer func() { _ = conn.Close() }()
+ _, _ = io.Copy(conn, conn)
+ }()
+ }
+ }()
+ return ln
+}
+
+// portOf returns the port a listener bound to.
+func portOf(ln net.Listener) int {
+ _, portStr, err := net.SplitHostPort(ln.Addr().String())
+ Expect(err).ToNot(HaveOccurred())
+ port, err := strconv.Atoi(portStr)
+ Expect(err).ToNot(HaveOccurred())
+ return port
+}
+
+// listenOnSecondLoopback binds 127.0.0.2 on a port that 127.0.0.1 does not
+// have anything on, and will not be given anything on.
+//
+// The port choice is the assertion's, not an incidental. The spec it serves
+// proves a reachability fact: only 127.0.0.2 is listening, so a service that
+// honoured the host the frontend named would connect and one that dials
+// loopback cannot. A port taken from :0 lands in the kernel's ephemeral range,
+// where some unrelated socket on 127.0.0.1 can be holding the same number, and
+// then the dial to 127.0.0.1 succeeds and the spec reports an SSRF that did not
+// happen. That is not hypothetical: it failed about one run in seven under
+// `-race` while passing every time in isolation.
+//
+// Choosing from BELOW the ephemeral range (32768 on Linux by default) is what
+// removes it, because the kernel does not hand those out for outbound
+// connections. 127.0.0.1 is probed and released rather than held: holding it
+// would make the dial the spec expects to fail succeed instead.
+func listenOnSecondLoopback() (net.Listener, int) {
+ GinkgoHelper()
+ const (
+ floor = 20000
+ ceiling = 31000
+ attempts = 200
+ )
+ for i := 0; i < attempts; i++ {
+ port := floor + rand.IntN(ceiling-floor)
+ free, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ continue
+ }
+ if err := free.Close(); err != nil {
+ continue
+ }
+ victim, err := net.Listen("tcp", fmt.Sprintf("127.0.0.2:%d", port))
+ if err != nil {
+ // A host with no second loopback address fails on every port, so
+ // this is the skip the spec used to make inline.
+ if i == 0 && strings.Contains(err.Error(), "assign requested address") {
+ Skip("this host cannot bind a second loopback address: " + err.Error())
+ }
+ continue
+ }
+ return victim, port
+ }
+ Fail(fmt.Sprintf("no port in [%d, %d) was free on 127.0.0.1 and bindable on 127.0.0.2 after %d attempts", floor, ceiling, attempts))
+ return nil, 0
+}
+
+// The routing table is the security boundary of the whole tunnel, and until now
+// nothing exercised it: every spec above installs dialLocalTCP, which is exactly
+// the permissive dialler loopbackService exists to prevent. A review turned
+// loopbackService into an arbitrary-host dialler and all 131 specs passed.
+var _ = Describe("Worker tunnel local services", func() {
+ var ctx context.Context
+
+ BeforeEach(func() { ctx = context.Background() })
+
+ Describe("loopbackService", func() {
+ It("reaches a loopback listener whose port is in range", func() {
+ ln := echoListenerOn("127.0.0.1:0")
+ DeferCleanup(func() { _ = ln.Close() })
+ port := portOf(ln)
+
+ conn, err := loopbackService(port, port)(ctx, ln.Addr().String())
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+
+ _, err = conn.Write([]byte("hi"))
+ Expect(err).ToNot(HaveOccurred())
+ buf := make([]byte, 2)
+ _, err = io.ReadFull(conn, buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf)).To(Equal("hi"))
+ })
+
+ It("ignores the host the frontend names and dials loopback anyway", func() {
+ ln := echoListenerOn("127.0.0.1:0")
+ DeferCleanup(func() { _ = ln.Close() })
+ port := portOf(ln)
+
+ // A host that is emphatically not this machine. If it were honoured
+ // the dial would fail or, far worse, succeed against something else.
+ conn, err := loopbackService(port, port)(ctx, fmt.Sprintf("attacker.invalid:%d", port))
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Expect(conn.RemoteAddr().String()).To(Equal(ln.Addr().String()))
+ })
+
+ It("does not reach a listener on another local address the frontend names", func() {
+ // The SSRF proof, stated as a reachability fact rather than as a
+ // property of the code. The only listener is on 127.0.0.2; nothing
+ // is on 127.0.0.1 at that port. A service that honoured the named
+ // host would connect; one that dials loopback cannot.
+ victim, port := listenOnSecondLoopback()
+ DeferCleanup(func() { _ = victim.Close() })
+
+ conn, err := loopbackService(port, port)(ctx, victim.Addr().String())
+ if err == nil {
+ _ = conn.Close()
+ Fail("the worker reached a host the frontend named, so a stream can steer it off loopback")
+ }
+ Expect(err).To(HaveOccurred())
+ })
+
+ DescribeTable("refuses a target it will not route",
+ func(target string, minPort, maxPort int) {
+ _, err := loopbackService(minPort, maxPort)(ctx, target)
+ Expect(err).To(HaveOccurred())
+ // Invalid, not unavailable. No retry brings a port outside this
+ // worker's own allocator range into it, and telling a frontend
+ // to retry forever is how a refusal becomes a hang.
+ Expect(err).To(MatchError(cluster.ErrStreamRequestInvalid))
+ Expect(err).ToNot(MatchError(cluster.ErrStreamTargetUnavailable))
+ },
+ Entry("a port below the range", "127.0.0.1:50050", 50051, 50060),
+ Entry("a port above the range", "127.0.0.1:50061", 50051, 50060),
+ Entry("a non-numeric port", "127.0.0.1:http", 50051, 50060),
+ Entry("no port at all", "127.0.0.1", 50051, 50060),
+ Entry("an empty target", "", 50051, 50060),
+ )
+
+ It("reports a backend that is not listening as unavailable, which a frontend may retry", func() {
+ // The other half of the taxonomy: a port IN range with nothing on
+ // it is a backend that has not started yet, not a bad request.
+ ln := echoListenerOn("127.0.0.1:0")
+ port := portOf(ln)
+ Expect(ln.Close()).To(Succeed())
+
+ _, err := loopbackService(port, port)(ctx, ln.Addr().String())
+ Expect(err).To(HaveOccurred())
+ Expect(classifyServiceFailure(err)).To(MatchError(cluster.ErrStreamTargetUnavailable))
+ Expect(classifyServiceFailure(err)).ToNot(MatchError(cluster.ErrStreamRequestInvalid))
+ })
+ })
+
+ Describe("fixedService", func() {
+ It("reaches its own address whatever the frontend names", func() {
+ ln := echoListenerOn("127.0.0.1:0")
+ DeferCleanup(func() { _ = ln.Close() })
+
+ conn, err := fixedService(ln.Addr().String())(ctx, "attacker.invalid:9")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Expect(conn.RemoteAddr().String()).To(Equal(ln.Addr().String()))
+ })
+ })
+
+ DescribeTable("loopbackAddr rewrites a bind address into a dialable one",
+ func(bind, want string) {
+ Expect(loopbackAddr(bind)).To(Equal(want))
+ },
+ // Dialling 0.0.0.0 only accidentally reaches localhost, and not on
+ // every platform, so the wildcard is replaced rather than dialled.
+ Entry("IPv4 wildcard", "0.0.0.0:8080", "127.0.0.1:8080"),
+ Entry("IPv6 wildcard", "[::]:8080", "127.0.0.1:8080"),
+ Entry("no host", ":8080", "127.0.0.1:8080"),
+ Entry("an explicit host is left alone", "10.0.0.9:8080", "10.0.0.9:8080"),
+ Entry("an explicit loopback is left alone", "127.0.0.1:8080", "127.0.0.1:8080"),
+ Entry("something that is not host:port passes through", "not-an-address", "not-an-address"),
+ )
+
+ Describe("tunnelServices", func() {
+ // The table Run installs. Built by its own function precisely so this
+ // can be asserted without starting a worker.
+ It("serves exactly the two tags the frontend may name", func() {
+ cfg := &Config{ServeAddr: "0.0.0.0:50051"}
+ Expect(tunnelServices(cfg, "0.0.0.0:50050")).To(HaveLen(2))
+ Expect(tunnelServices(cfg, "0.0.0.0:50050")).To(HaveKey(cluster.StreamTagGRPC))
+ Expect(tunnelServices(cfg, "0.0.0.0:50050")).To(HaveKey(cluster.StreamTagHTTP))
+ })
+
+ It("bounds the gRPC service by THIS worker's configured port range", func() {
+ cfg := &Config{ServeAddr: "0.0.0.0:50051", GRPCMaxPort: 50052}
+ svc := tunnelServices(cfg, "0.0.0.0:50050")[cluster.StreamTagGRPC]
+
+ // The HTTP server's own port sits one below the base port, so a
+ // gRPC-tagged stream cannot be steered onto it.
+ _, err := svc(ctx, "127.0.0.1:50050")
+ Expect(err).To(MatchError(cluster.ErrStreamRequestInvalid))
+ _, err = svc(ctx, "127.0.0.1:50053")
+ Expect(err).To(MatchError(cluster.ErrStreamRequestInvalid))
+ })
+
+ // Pins that the HTTP service reaches the address Run configures. It
+ // does NOT pin the wildcard rewrite: on Linux dialling 0.0.0.0 reaches
+ // loopback anyway, so this spec stays green with loopbackAddr disabled.
+ // The loopbackAddr table above is what holds that, and it exists
+ // because the accident is not portable.
+ It("points the HTTP service at the worker's own server", func() {
+ ln := echoListenerOn("127.0.0.1:0")
+ DeferCleanup(func() { _ = ln.Close() })
+
+ cfg := &Config{ServeAddr: "0.0.0.0:50051"}
+ svc := tunnelServices(cfg, fmt.Sprintf("0.0.0.0:%d", portOf(ln)))[cluster.StreamTagHTTP]
+
+ conn, err := svc(ctx, "ignored:1")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = conn.Close() })
+ Expect(conn.RemoteAddr().String()).To(Equal(ln.Addr().String()))
+ })
+ })
+
+ DescribeTable("tunnelEndpoint builds the URL the worker dials",
+ func(frontendURL, nodeID, want string) {
+ got, err := tunnelEndpoint(frontendURL, nodeID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got).To(Equal(want))
+ },
+ Entry("http becomes ws", "http://frontend:8080", "n1", "ws://frontend:8080/api/cluster/connect?id=n1"),
+ Entry("https becomes wss", "https://frontend", "n1", "wss://frontend/api/cluster/connect?id=n1"),
+ Entry("ws passes through", "ws://frontend:8080", "n1", "ws://frontend:8080/api/cluster/connect?id=n1"),
+ Entry("wss passes through", "wss://frontend", "n1", "wss://frontend/api/cluster/connect?id=n1"),
+ // A frontend behind a path prefix keeps it: the path is appended, not
+ // assigned, exactly as the registration client builds its URLs.
+ Entry("a path prefix is kept", "https://host/localai", "n1", "wss://host/localai/api/cluster/connect?id=n1"),
+ Entry("a trailing slash is not doubled", "https://host/localai/", "n1", "wss://host/localai/api/cluster/connect?id=n1"),
+ Entry("the node id is escaped", "http://h", "a b&c", "ws://h/api/cluster/connect?id=a+b%26c"),
+ )
+
+ DescribeTable("tunnelEndpoint refuses a frontend URL it cannot dial",
+ func(frontendURL string) {
+ _, err := tunnelEndpoint(frontendURL, "n1")
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("empty", ""),
+ // Refused rather than coerced: a worker silently dialling a scheme
+ // nobody configured is worse than one that says it cannot start.
+ Entry("a scheme that is not HTTP", "ftp://frontend"),
+ Entry("a bare host with no scheme", "frontend:8080/x"),
+ Entry("no host", "http://"),
+ )
+})
diff --git a/core/services/worker/worker.go b/core/services/worker/worker.go
index 6434c3cd6b69..374a9cbd6a95 100644
--- a/core/services/worker/worker.go
+++ b/core/services/worker/worker.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "net/http"
"os"
"os/signal"
"path/filepath"
@@ -16,26 +17,26 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
- "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
grpc "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
- "github.com/mudler/LocalAI/pkg/sanitize"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
)
-// Run starts the distributed agent worker: registers with the frontend,
-// subscribes to NATS lifecycle subjects, and blocks on signals.
+// Run starts the distributed agent worker: registers with the frontend, opens
+// its tunnel, serves its control plane on that tunnel, and blocks on signals.
func Run(ctx *cliContext.Context, cfg *Config) error {
- xlog.Info("Starting worker", "advertise", cfg.advertiseAddr(), "basePort", cfg.effectiveBasePort())
+ xlog.Info("Starting worker", "basePort", cfg.effectiveBasePort())
- // Fail fast (before prefetch/registration/NATS) when enforcement is on but no
- // registration token is set: the worker's HTTP file-transfer server fails
- // open on an empty token (see nodes.checkBearerToken), so refuse to start
- // rather than register and then die mid-boot.
- if cfg.RegistrationAuthRequired() && cfg.RegistrationToken == "" {
- return fmt.Errorf("registration auth is required (LOCALAI_REGISTRATION_REQUIRE_AUTH or LOCALAI_DISTRIBUTED_REQUIRE_AUTH) but LOCALAI_REGISTRATION_TOKEN is empty — refusing to start an unauthenticated file-transfer server")
+ // Fail fast, before prefetch and registration, on any configuration
+ // that would produce a worker the cluster believes in and cannot use. See
+ // validateStartup for what those are and why each is fatal rather than
+ // degraded.
+ if err := cfg.validateStartup(); err != nil {
+ return err
}
systemState, err := system.GetSystemState(
@@ -62,7 +63,7 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
}
// Prefetch gallery models over the worker's outbound internet before we
- // start accepting backend.install events. Non-fatal on every failure path:
+ // serve backend installs. Non-fatal on every failure path:
// if the gallery is unreachable, an ID is unknown, or LOCALAI_GALLERIES is
// malformed, the worker still starts and the master can push files on
// demand (existing fallback behaviour). Placed BEFORE registration so a
@@ -84,58 +85,46 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
defer shutdownCancel()
registrationBody := cfg.registrationBody()
- natsTLS := messaging.TLSFiles{CA: cfg.NatsTLSCA, Cert: cfg.NatsTLSCert, Key: cfg.NatsTLSKey}
- // Resolve how to connect to NATS. Static env credentials cannot be re-minted,
- // so register once and use them directly. Otherwise the credential manager
- // (re)registers to obtain credentials — waiting through admin approval — and
- // refreshes them before the minted JWT expires, so the connection survives
- // expiry via a transparent reconnect.
- var (
- nodeID string
- connectNats func() (*messaging.Client, error)
- )
- if cfg.NatsJWT != "" || cfg.NatsUserSeed != "" {
- nid, _, _, _, regErr := regClient.RegisterWithRetry(shutdownCtx, registrationBody, 10)
- if regErr != nil {
- return fmt.Errorf("failed to register with frontend: %w", regErr)
- }
- nodeID = nid
- connectNats = func() (*messaging.Client, error) {
- return connectNATS(cfg.NatsURL, cfg.NatsJWT, cfg.NatsUserSeed, "", "", cfg.NatsAuthRequired(), natsTLS)
- }
- } else {
- credMgr := workerregistry.NewNATSCredentialManager(
- func(ctx context.Context) (*workerregistry.RegisterResponse, error) {
- return regClient.RegisterFull(ctx, registrationBody)
- },
- cfg.NatsAuthRequired(),
- )
- res, regErr := credMgr.Acquire(shutdownCtx)
- if regErr != nil {
- return fmt.Errorf("failed to register with frontend: %w", regErr)
- }
- nodeID = res.ID
- connectNats = func() (*messaging.Client, error) {
- var opts []messaging.Option
- if credMgr.HasCredentials() {
- opts = append(opts, messaging.WithUserJWTProvider(credMgr.Provider()))
- }
- if natsTLS.Enabled() {
- opts = append(opts, messaging.WithTLS(natsTLS))
- }
- client, cerr := messaging.New(cfg.NatsURL, opts...)
- if cerr == nil && credMgr.HasCredentials() {
- go func() {
- if err := credMgr.RefreshLoop(shutdownCtx); err != nil {
- xlog.Error("NATS credential refresh permanently failed; shutting down worker", "error", err)
- shutdownCancel()
- }
- }()
- }
- return client, cerr
- }
+ // One registration, and the tunnel credential it returns is the only
+ // credential a backend worker holds. There is no second acquisition path:
+ // the bus this worker used to also authenticate against is gone from its
+ // startup entirely.
+ //
+ // This path registers exactly once and never again, so the credential it
+ // holds cannot go stale by rotation from its own side.
+ //
+ // It CAN be superseded from outside: Register upserts by NAME, so a second
+ // worker registering under this node's name rotates the row's credential,
+ // and this worker then fails every tunnel dial with 401 for the life of the
+ // process. It logs that once per backoff and never recovers on its own; a
+ // restart fixes it only until the other worker registers again.
+ //
+ // Still deliberately not auto-re-registered, and now for a concrete reason
+ // rather than a deferral. Register CLEARS this node's NodeModel rows, on
+ // the assumption that a re-registering worker restarted with nothing
+ // loaded, so re-registering on a 401 would delete a live worker's replica
+ // rows on every retry, and under the name collision that produces the 401
+ // the two workers would take turns doing it forever. That is a credential
+ // failure causing model reclamation, which is the one outcome this whole
+ // design exists to prevent. It is also why the NATS credential manager,
+ // whose refresh loop re-registered on a timer, is no longer on this path:
+ // it was wiping a live worker's rows every time it renewed a JWT.
+ //
+ // The fix belongs to whichever comes first: a re-auth path that mints a
+ // tunnel credential WITHOUT the rest of registration's side effects, or a
+ // worker identity that is not the operator-chosen name, which is what would
+ // make a collision detectable instead of silent. Until then the 401 is
+ // loud, names both causes, and the operator acts on it.
+ res, err := regClient.RegisterFullWithRetry(shutdownCtx, registrationBody, 10)
+ if err != nil {
+ return fmt.Errorf("failed to register with frontend: %w", err)
}
+ nodeID := res.ID
+ // Read through a function because StartTunnel presents the credential at
+ // DIAL time, not at start time; there is one value behind it today and the
+ // indirection is what keeps a future rotation from needing a new dial path.
+ tunnelToken := func() string { return res.TunnelToken }
xlog.Info("Registered with frontend", "nodeID", nodeID, "frontend", cfg.RegisterTo)
heartbeatInterval, err := time.ParseDuration(cfg.HeartbeatInterval)
@@ -148,77 +137,42 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
// the top of Run so the worker fails before registering.)
httpAddr := cfg.resolveHTTPAddr()
stagingDir := filepath.Join(cfg.ModelsPath, "..", "staging")
- dataDir := filepath.Join(cfg.ModelsPath, "..", "data")
- // The readiness gate is created here but only armed once NATS is up, below.
- // Until then /readyz reports ready, which is correct: reaching this line
- // means the worker has already registered with the frontend, so it is
+ // Derived through the same helper the listdir verb resolves `data/` keys
+ // against, and not a second time here. Two independent joins that agreed
+ // today would each stay self consistent if one moved, and the symptom would
+ // be a verb that lists files the file server does not serve.
+ dataDir := cfg.stagingDataDir()
+ // The readiness gate is created here but only armed once the tunnel exists,
+ // below. Until then /readyz reports ready, which is correct: reaching this
+ // line means the worker has already registered with the frontend, so it is
// mid-startup rather than broken.
readiness := &nodes.WorkerReadiness{}
- httpServer, err := nodes.StartFileTransferServer(httpAddr, stagingDir, cfg.ModelsPath, dataDir, cfg.RegistrationToken, config.DefaultMaxUploadSize, readiness, ml.BackendLogs())
- if err != nil {
- return fmt.Errorf("starting HTTP file transfer server: %w", err)
- }
- // Per-request input files land in stagingDir over that server and nothing
- // used to remove them, so a long-lived worker filled its own disk.
- StartEphemeralStagingCleanup(shutdownCtx, stagingDir, 0, 0)
-
- // Connect to NATS
- xlog.Info("Connecting to NATS", "url", sanitize.URL(cfg.NatsURL))
- natsClient, err := connectNats()
- if err != nil {
- nodes.ShutdownFileTransferServer(httpServer)
- return fmt.Errorf("connecting to NATS: %w", err)
- }
- defer natsClient.Close()
-
- // Arm the readiness gate now that the worker can actually receive work.
- // From here /readyz tracks the live NATS link, so a worker that is up but
- // cut off from the bus reports 503 instead of a meaningless 200 (#10987).
- readiness.Set(nodes.NATSReadiness(natsClient))
-
- // Start heartbeat goroutine (after NATS is connected so IsConnected check works)
- go func() {
- ticker := time.NewTicker(heartbeatInterval)
- defer ticker.Stop()
- for {
- select {
- case <-shutdownCtx.Done():
- return
- case <-ticker.C:
- if !natsClient.IsConnected() {
- xlog.Warn("Skipping heartbeat: NATS disconnected")
- continue
- }
- body := cfg.heartbeatBody()
- if err := regClient.Heartbeat(shutdownCtx, nodeID, body); err != nil {
- xlog.Warn("Heartbeat failed", "error", err)
- }
- }
- }
- }()
-
- // Process supervisor — manages multiple backend gRPC processes on different ports
+ // The supervisor is built BEFORE the HTTP server, not after, because the
+ // server is what serves its control plane. Ten NATS subscriptions used to
+ // be attached to it later, and could be, because the bus buffered nothing
+ // the worker had not subscribed to; a control route that is not mounted
+ // when the tunnel comes up is instead a 404 the frontend reads as a worker
+ // that does not implement the verb.
basePort := cfg.effectiveBasePort()
- // Buffered so NATS stop handler can send without blocking
+ // Buffered so the node.stop verb can signal without blocking its response.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
- // Set the registration token once before any backends are started
+ // Set the registration token once before any backends are started.
if cfg.RegistrationToken != "" {
if err := os.Setenv(grpc.AuthTokenEnvVar, cfg.RegistrationToken); err != nil {
- nodes.ShutdownFileTransferServer(httpServer)
return fmt.Errorf("setting backend authentication token: %w", err)
}
}
+ // Process supervisor — manages multiple backend gRPC processes on different ports
supervisor := &backendSupervisor{
cfg: cfg,
ml: ml,
systemState: systemState,
galleries: galleries,
nodeID: nodeID,
- nats: natsClient,
sigCh: sigCh,
processes: make(map[string]*backendProcess),
portAffinity: make(map[string]portOwnership),
@@ -226,35 +180,162 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
minPort: basePort,
maxPort: cfg.effectiveMaxPort(basePort),
}
- if err := supervisor.subscribeLifecycleEvents(); err != nil {
- nodes.ShutdownFileTransferServer(httpServer)
- return fmt.Errorf("subscribing to worker lifecycle events: %w", err)
- }
- // Subscribe to file staging NATS subjects if S3 is configured
+ // The file-staging FileManager is built BEFORE the server, for the same
+ // reason the supervisor is: the server is what serves those four verbs, and
+ // a route that is not mounted when the tunnel comes up is a 404 the
+ // frontend reads as a worker that does not implement the verb. It used to
+ // be built after NATS connected, which is a window that no longer exists.
+ //
+ // A worker with no object store configured mounts NO file verbs. That is
+ // the honest answer rather than a degraded one: it has nowhere to fetch
+ // from or stage to, and the frontend that reaches such a deployment uses
+ // the HTTP file stager, which does not call these paths at all.
+ var stagingFM *storage.FileManager
if cfg.StorageURL != "" {
- if err := cfg.subscribeFileStaging(natsClient, nodeID); err != nil {
- nodes.ShutdownFileTransferServer(httpServer)
- return fmt.Errorf("subscribing to file staging subjects: %w", err)
+ stagingFM, err = cfg.NewStagingFileManager(shutdownCtx)
+ if err != nil {
+ return fmt.Errorf("initializing file staging: %w", err)
}
}
- xlog.Info("Worker ready, waiting for backend.install events")
- // Exit on an OS signal or on an internal fatal condition (e.g. NATS
- // credentials became unrenewable), so the worker restarts and re-acquires
- // rather than lingering unable to serve.
- var runErr error
- select {
- case <-sigCh:
- case <-shutdownCtx.Done():
- runErr = fmt.Errorf("worker shutting down: NATS credentials unavailable")
- xlog.Error("Internal shutdown requested", "error", runErr)
+ httpServer, err := startWorkerHTTPServer(httpAddr, stagingDir, cfg.ModelsPath, dataDir,
+ cfg.RegistrationToken, readiness, supervisor, cfg, stagingFM, ml.BackendLogs())
+ if err != nil {
+ return fmt.Errorf("starting HTTP file transfer server: %w", err)
}
+ // Per-request input files land in stagingDir over that server and nothing
+ // used to remove them, so a long-lived worker filled its own disk.
+ StartEphemeralStagingCleanup(shutdownCtx, stagingDir, 0, 0)
+
+ // The tunnel is started here, after the HTTP server it fronts is listening
+ // and before any backend process exists. Both orders are deliberate: a
+ // stream tagged for HTTP that arrived before the server bound would be
+ // refused as unavailable, while a stream tagged for gRPC resolves its
+ // backend at dial time, so nothing has to exist yet for the tunnel to be
+ // useful.
+ //
+ // A failure to START it is fatal, unlike a failure to CONNECT: it means the
+ // frontend URL or this node's identity is unusable, and a worker that
+ // silently ran without its tunnel would look healthy while being
+ // unreachable to everything that dials through it.
+ //
+ // Unconditional: LOCALAI_WORKER_TUNNEL=false is refused by validateStartup
+ // before this point, so there is no configuration that reaches here without
+ // one. A guard here would be a branch nothing can take, which reads as a
+ // supported no-tunnel mode that does not exist.
+ tunnel, terr := startTunnelAndArmReadiness(shutdownCtx, readiness, TunnelConfig{
+ FrontendURL: cfg.RegisterTo,
+ NodeID: nodeID,
+ Token: tunnelToken,
+ // Built by tunnelServices rather than inline, so the routing
+ // table, which is this feature's security boundary, is reachable
+ // from a spec without starting a worker.
+ Services: tunnelServices(cfg, httpAddr),
+ })
+ if terr != nil {
+ nodes.ShutdownFileTransferServer(httpServer)
+ return fmt.Errorf("starting the worker tunnel: %w", terr)
+ }
+ defer func() {
+ if err := tunnel.Close(); err != nil {
+ xlog.Warn("Closing the worker tunnel failed", "error", err)
+ }
+ }()
+
+ ticker := time.NewTicker(heartbeatInterval)
+ defer ticker.Stop()
+ go heartbeatLoop(shutdownCtx, ticker.C, func(ctx context.Context) error {
+ return regClient.Heartbeat(ctx, nodeID, cfg.heartbeatBody())
+ })
+
+ xlog.Info("Worker ready, serving its control plane over the tunnel")
+ <-sigCh
+
xlog.Info("Shutting down worker")
shutdownCancel() // stop heartbeat loop immediately
regClient.GracefulDeregister(nodeID)
supervisor.stopAllBackends(false)
nodes.ShutdownFileTransferServer(httpServer)
- return runErr
+ return nil
+}
+
+// heartbeatLoop posts this worker's heartbeat on every tick until ctx ends.
+//
+// It is given no view of the tunnel, and that absence is the point rather than
+// an omission. The heartbeat is the WORKER'S OWN ANSWER that its process is
+// alive; whether the frontend can REACH this worker is a separate fact, which
+// the frontend reads from the tunnel session it holds and ages against
+// LOCALAI_WORKER_RECONNECT_GRACE. Withholding the heartbeat while the tunnel
+// re-homes would report an unreachable worker as an absent one, on the one path
+// that has no grace at all: the health monitor marks a silent node offline or
+// unhealthy and its pending backend ops are deleted behind it.
+//
+// A failed post is likewise not a reason to stop. The frontend being briefly
+// unreachable is the exact moment a worker must keep trying, and a loop that
+// returned here would silence a healthy worker for the rest of its life after
+// one frontend restart.
+func heartbeatLoop(ctx context.Context, tick <-chan time.Time, send func(context.Context) error) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick:
+ if err := send(ctx); err != nil {
+ xlog.Warn("Heartbeat failed", "error", err)
+ }
+ }
+ }
+}
+
+// startTunnelAndArmReadiness starts the worker's tunnel and points the
+// readiness gate at it.
+//
+// One call rather than two lines at the call site, because the gate and the
+// tunnel are one fact. /readyz means "the frontend can reach me", and a live
+// tunnel session is the only thing that makes that true: the worker binds
+// loopback, advertises no address, and every request the frontend makes of it
+// arrives as a stream inside that session. Armed as a separate statement, the
+// arming is a line whose loss has no symptom - the gate fails open, so the
+// worker answers 200 forever with no session, which is issue #10987 back
+// again and nothing else in the process would say a word.
+//
+// A tunnel that fails to START leaves the gate as it found it. There is no
+// worker to report on: Run turns that into a fatal error before anything else
+// happens.
+func startTunnelAndArmReadiness(ctx context.Context, readiness *nodes.WorkerReadiness, cfg TunnelConfig) (*Tunnel, error) {
+ t, err := StartTunnel(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ readiness.Set(nodes.TunnelReadiness(t))
+ return t, nil
+}
+
+// startWorkerHTTPServer starts the worker's loopback HTTP server with sup's
+// control plane mounted on it.
+//
+// It takes the supervisor rather than an optional route set on purpose: the
+// control plane and the file routes are served by one listener behind one
+// bearer check, and there is no worker that wants the second without the first.
+// Making the supervisor a parameter is what stops a future edit from starting
+// the server without the control plane and producing a worker that looks
+// healthy while answering 404 to every command.
+func startWorkerHTTPServer(addr, stagingDir, modelsDir, dataDir, token string,
+ readiness *nodes.WorkerReadiness, sup *backendSupervisor, cfg *Config,
+ stagingFM *storage.FileManager, logStore *model.BackendLogStore) (*http.Server, error) {
+ return nodes.StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token,
+ config.DefaultMaxUploadSize, readiness, &nodes.AuthenticatedRoutes{
+ Prefix: workerctl.Prefix,
+ // One registrar for both route sets, because there is ONE control
+ // prefix and AuthenticatedRoutes mounts one mux behind one bearer
+ // check. A second route set would be a second check to forget.
+ Register: func(mux *http.ServeMux) {
+ sup.RegisterControlRoutes(mux)
+ if stagingFM != nil {
+ cfg.RegisterFileControlRoutes(mux, stagingFM)
+ }
+ },
+ }, logStore)
}
diff --git a/core/services/workerctl/paths.go b/core/services/workerctl/paths.go
new file mode 100644
index 000000000000..4af1164d843d
--- /dev/null
+++ b/core/services/workerctl/paths.go
@@ -0,0 +1,86 @@
+// Package workerctl names the HTTP control plane a worker serves to the
+// frontends that manage it.
+//
+// It is a leaf on the standard library alone, and deliberately so: the worker
+// registers these paths and the frontend calls them, so both sides must agree
+// on the literals without either importing the other's package.
+package workerctl
+
+import "encoding/json"
+
+// Prefix is the one path prefix the worker mounts its whole control plane
+// under. Everything the frontend may command a worker to do lives below it,
+// which is what lets the worker put the control plane behind a single
+// authentication check instead of one per verb.
+const Prefix = "/v1/control/"
+
+// The control verbs. Each replaces one NATS subject; the request and reply
+// bodies are the messaging DTOs those subjects already carried, unchanged, so
+// a worker still reachable over NATS and one reachable over the tunnel answer
+// with the same bytes.
+const (
+ PathBackendInstall = "/v1/control/backend/install"
+ PathBackendUpgrade = "/v1/control/backend/upgrade"
+ PathBackendList = "/v1/control/backend/list"
+ PathBackendStop = "/v1/control/backend/stop"
+ PathBackendDelete = "/v1/control/backend/delete"
+ PathModelStop = "/v1/control/model/stop"
+ PathModelUnload = "/v1/control/model/unload"
+ PathModelDelete = "/v1/control/model/delete"
+ PathModelsRunning = "/v1/control/models/running"
+ PathNodeStop = "/v1/control/node/stop"
+
+ // The file-staging verbs. They are only ever served by a worker whose
+ // deployment configured an object store, because without one there is
+ // nothing for them to move a file to or from; a worker without one mounts
+ // them not at all, and the catch-all under Prefix answers for them. That is
+ // the same 404 an older build gives, which is what the frontend already
+ // reads as "this worker does not serve that verb" rather than as absence.
+ PathFilesEnsure = "/v1/control/files/ensure"
+ PathFilesStage = "/v1/control/files/stage"
+ PathFilesTemp = "/v1/control/files/temp"
+ PathFilesListDir = "/v1/control/files/listdir"
+)
+
+// AllPaths returns every control verb's path.
+//
+// It exists so a spec can assert a property of the whole set rather than of a
+// list it re-types, which would go stale the moment a verb is added.
+func AllPaths() []string {
+ return []string{
+ PathBackendInstall,
+ PathBackendUpgrade,
+ PathBackendList,
+ PathBackendStop,
+ PathBackendDelete,
+ PathModelStop,
+ PathModelUnload,
+ PathModelDelete,
+ PathModelsRunning,
+ PathNodeStop,
+ PathFilesEnsure,
+ PathFilesStage,
+ PathFilesTemp,
+ PathFilesListDir,
+ }
+}
+
+// Envelope is one line of a streaming control response.
+//
+// Exactly one of the two is set. Zero or more Progress lines are followed by
+// exactly ONE Reply line, and the Reply line is the last thing on the body.
+// That ordering is the contract: it is what lets the frontend stop reading, and
+// it is what replaces the subscribe-before-request dance the NATS carrier
+// needed, since progress and reply now share one response and nothing can
+// arrive before the caller is listening.
+//
+// Progress carrying the reply's own bytes is also why the 8000-byte
+// notification cap that bounded the NATS progress subject has no analogue here:
+// a line is written into the response body the caller is already reading.
+type Envelope struct {
+ Progress json.RawMessage `json:"progress,omitempty"`
+ Reply json.RawMessage `json:"reply,omitempty"`
+}
+
+// ContentTypeStream is the media type of a streaming control response.
+const ContentTypeStream = "application/x-ndjson"
diff --git a/core/services/workerctl/paths_test.go b/core/services/workerctl/paths_test.go
new file mode 100644
index 000000000000..4d72124ec0c9
--- /dev/null
+++ b/core/services/workerctl/paths_test.go
@@ -0,0 +1,94 @@
+package workerctl_test
+
+import (
+ "encoding/json"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// The literals below are written out BY HAND and are deliberately not derived
+// from the constants under test. A spec that says PathBackendInstall equals
+// PathBackendInstall pins nothing: a rename moves both sides at once and stays
+// green. What these paths actually are is a cross-version contract, because a
+// frontend and a worker built from different commits reach each other over
+// them, and a renamed path is a 404 that looks exactly like a broken tunnel.
+var _ = Describe("control plane paths on the wire", func() {
+ DescribeTable("is the exact path both sides agree on",
+ func(got, want string) { Expect(got).To(Equal(want)) },
+ Entry("install", workerctl.PathBackendInstall, "/v1/control/backend/install"),
+ Entry("upgrade", workerctl.PathBackendUpgrade, "/v1/control/backend/upgrade"),
+ Entry("list", workerctl.PathBackendList, "/v1/control/backend/list"),
+ Entry("backend stop", workerctl.PathBackendStop, "/v1/control/backend/stop"),
+ Entry("backend delete", workerctl.PathBackendDelete, "/v1/control/backend/delete"),
+ Entry("model stop", workerctl.PathModelStop, "/v1/control/model/stop"),
+ Entry("model unload", workerctl.PathModelUnload, "/v1/control/model/unload"),
+ Entry("model delete", workerctl.PathModelDelete, "/v1/control/model/delete"),
+ Entry("models running", workerctl.PathModelsRunning, "/v1/control/models/running"),
+ Entry("node stop", workerctl.PathNodeStop, "/v1/control/node/stop"),
+ Entry("files ensure", workerctl.PathFilesEnsure, "/v1/control/files/ensure"),
+ Entry("files stage", workerctl.PathFilesStage, "/v1/control/files/stage"),
+ Entry("files temp", workerctl.PathFilesTemp, "/v1/control/files/temp"),
+ Entry("files listdir", workerctl.PathFilesListDir, "/v1/control/files/listdir"),
+ )
+
+ It("names the prefix exactly, since the worker mounts its whole control plane behind it", func() {
+ Expect(workerctl.Prefix).To(Equal("/v1/control/"))
+ })
+
+ It("puts every path under the one prefix", func() {
+ for _, p := range workerctl.AllPaths() {
+ Expect(p).To(HavePrefix(workerctl.Prefix))
+ }
+ })
+
+ It("lists every verb this package names, so none can be dropped from the set", func() {
+ // The claim is bounded on purpose. Go constants are not enumerable, so
+ // nothing here can see a NEW constant that was never added to AllPaths;
+ // what this catches is an EXISTING verb going missing from it, which
+ // matters because the prefix check above and the worker's mounting spec
+ // both iterate AllPaths and would silently stop covering it.
+ Expect(workerctl.AllPaths()).To(ConsistOf(
+ workerctl.PathBackendInstall,
+ workerctl.PathBackendUpgrade,
+ workerctl.PathBackendList,
+ workerctl.PathBackendStop,
+ workerctl.PathBackendDelete,
+ workerctl.PathModelStop,
+ workerctl.PathModelUnload,
+ workerctl.PathModelDelete,
+ workerctl.PathModelsRunning,
+ workerctl.PathNodeStop,
+ workerctl.PathFilesEnsure,
+ workerctl.PathFilesStage,
+ workerctl.PathFilesListDir,
+ workerctl.PathFilesTemp,
+ ))
+ })
+
+ It("gives each verb a distinct path", func() {
+ seen := map[string]bool{}
+ for _, p := range workerctl.AllPaths() {
+ Expect(seen[p]).To(BeFalse(), "duplicate control path %q", p)
+ seen[p] = true
+ }
+ })
+
+ It("marshals an envelope with exactly one populated field", func() {
+ b, err := json.Marshal(workerctl.Envelope{Reply: json.RawMessage(`{"success":true}`)})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(b)).To(Equal(`{"reply":{"success":true}}`))
+ })
+
+ It("marshals a progress envelope without a reply key, which is what ends the body", func() {
+ b, err := json.Marshal(workerctl.Envelope{Progress: json.RawMessage(`{"percentage":50}`)})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(b)).To(Equal(`{"progress":{"percentage":50}}`))
+ })
+
+ It("names the streaming media type", func() {
+ Expect(workerctl.ContentTypeStream).To(Equal("application/x-ndjson"))
+ })
+})
diff --git a/core/services/workerctl/workerctl_suite_test.go b/core/services/workerctl/workerctl_suite_test.go
new file mode 100644
index 000000000000..a47f1c8ffdfc
--- /dev/null
+++ b/core/services/workerctl/workerctl_suite_test.go
@@ -0,0 +1,13 @@
+package workerctl_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestWorkerctl(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Workerctl test suite")
+}
diff --git a/docker-compose.distributed.yaml b/docker-compose.distributed.yaml
index 3387e313415b..df01b452c988 100644
--- a/docker-compose.distributed.yaml
+++ b/docker-compose.distributed.yaml
@@ -104,21 +104,28 @@ services:
- BASE_IMAGE=ubuntu:24.04
command:
- worker
- # No HEALTHCHECK_ENDPOINT override is needed: the image's healthcheck
+ # No published ports and no advertised address: the worker holds one
+ # outbound tunnel to the frontend and binds only loopback, so nothing has to
+ # reach into this container.
+ #
+ # No HEALTHCHECK_ENDPOINT override is needed either: the image's healthcheck
# detects worker mode and derives the port from LOCALAI_SERVE_ADDR below
- # (gRPC base port - 1 = 50050). The worker's /readyz reports 503 while its
- # NATS connection is down, so `unhealthy` here means the worker genuinely
- # cannot receive work.
+ # (gRPC base port - 1 = 50050). It runs inside the container, so a loopback
+ # bind is enough for it. The worker's /readyz reports 503 while it holds no
+ # tunnel session, so `unhealthy` here means the frontend genuinely cannot
+ # reach this worker.
+ #
+ # No LOCALAI_NATS_URL and no dependency on the nats service: a backend
+ # worker connects to no bus. Everything the frontend asks of it travels the
+ # tunnel this container dials out to localai:8080. The frontend and the
+ # agent worker below still need NATS.
environment:
LOCALAI_SERVE_ADDR: "0.0.0.0:50051"
- LOCALAI_ADVERTISE_ADDR: "worker-1:50051"
- LOCALAI_ADVERTISE_HTTP_ADDR: "worker-1:50050"
DEBUG: "true"
LOCALAI_REGISTER_TO: "http://localai:8080"
LOCALAI_NODE_NAME: "worker-1"
LOCALAI_REGISTRATION_TOKEN: "changeme" # Must match frontend token
LOCALAI_HEARTBEAT_INTERVAL: "10s"
- LOCALAI_NATS_URL: "nats://nats:4222"
GODEBUG: "netdns=go" # See note in localai service
MODELS_PATH: /models
volumes:
@@ -126,8 +133,6 @@ services:
depends_on:
localai:
condition: service_started
- nats:
- condition: service_started
# --- GPU Support (NVIDIA) ---
# Uncomment the following and change the image to a CUDA variant
@@ -175,7 +180,11 @@ services:
# Copy the worker-1 service above and change:
# - Service name (e.g., worker-2)
# - LOCALAI_NODE_NAME (must be unique)
- # - LOCALAI_ADVERTISE_ADDR (must match service name)
+ #
+ # Nothing else. A worker has no address to make unique: it binds loopback
+ # inside its own container and dials out to the frontend. Note that
+ # LOCALAI_NODE_NAME really must differ: the registry upserts by name, so two
+ # workers sharing one steal each other's row and each other's tunnel credential.
#
# Workers are generic — no backend type needed. The SmartRouter
# will dynamically install the required backend via NATS when
diff --git a/docs/content/features/distributed-mode.md b/docs/content/features/distributed-mode.md
index 0231c2dc4a52..856c622c89a6 100644
--- a/docs/content/features/distributed-mode.md
+++ b/docs/content/features/distributed-mode.md
@@ -17,7 +17,7 @@ Distributed mode requires authentication enabled with a **PostgreSQL** database
**Frontends** are stateless LocalAI instances that receive API requests and route them to worker nodes via the **SmartRouter**. All frontends share state through PostgreSQL and coordinate via NATS.
-**Workers** are generic processes that self-register with a frontend. They don't have a fixed backend type - the SmartRouter dynamically installs the required backend via NATS `backend.install` events when a model request arrives.
+**Workers** are generic processes that self-register with a frontend. They don't have a fixed backend type - the SmartRouter dynamically installs the required backend by calling the worker's `backend.install` control route through its tunnel when a model request arrives.
### Scheduling Algorithm
@@ -30,7 +30,7 @@ The SmartRouter uses **idle-first** scheduling with **preemptive eviction**:
4. Fall back to idle nodes (zero models), then least-loaded nodes
5. If no node has capacity → **evict the least-recently-used model with zero in-flight requests** to free a node
6. If all models are busy → wait (with timeout) for a model to become idle, then evict
-7. Send `backend.install` NATS event with backend name + model ID → worker starts a new gRPC process on a dynamic port
+7. `POST /v1/control/backend/install` through the worker's tunnel with backend name + model ID → worker starts a new gRPC process on a dynamic port
8. SmartRouter calls gRPC `LoadModel` on the model-specific port, records in DB
Each model gets its own gRPC backend process, so a single worker can serve multiple models simultaneously (e.g., a chat model and an embedding model).
@@ -38,7 +38,7 @@ Each model gets its own gRPC backend process, so a single worker can serve multi
## Prerequisites
- **PostgreSQL** (with pgvector extension recommended for RAG) - used for node registry, job store, auth, and shared state
-- **NATS** server - used for real-time backend lifecycle events and file staging
+- **NATS** server - used for agent-worker coordination and the frontend's own cross-replica events. **Serve-backend workers do not connect to it at all**: every verb they take, and file staging with it, is an HTTP route on the worker's tunnel. Set no `LOCALAI_NATS_URL` on a `local-ai worker`. The frontend and any `local-ai agent-worker` still need one.
- All services must be on the same network (or reachable via configured URLs)
## Quick Start with Docker Compose
@@ -64,6 +64,7 @@ The frontend is a standard LocalAI instance with distributed mode enabled. These
| `--distributed` | `LOCALAI_DISTRIBUTED` | `false` | Enable distributed mode |
| `--instance-id` | `LOCALAI_INSTANCE_ID` | auto UUID | Unique instance ID for this frontend |
| `--nats-url` | `LOCALAI_NATS_URL` | *(required)* | NATS server URL (e.g., `nats://localhost:4222`) |
+| `--distributed-advertise-addr` | `LOCALAI_DISTRIBUTED_ADVERTISE_ADDR` | *(derived)* | `host:port` the **other frontend replicas** dial to reach this one. See [Replica peer links](#replica-peer-links). |
| `--registration-token` | `LOCALAI_REGISTRATION_TOKEN` | *(empty)* | Token that workers must provide to register |
| `--registration-require-auth` | `LOCALAI_REGISTRATION_REQUIRE_AUTH` | `false` | Fail startup when distributed mode is enabled but the registration token is empty (node endpoints and worker file-transfer would otherwise be unauthenticated) |
| `--distributed-require-auth` | `LOCALAI_DISTRIBUTED_REQUIRE_AUTH` | `false` | **Umbrella switch.** Implies both `--nats-require-auth` and `--registration-require-auth` - one knob to lock down the NATS bus *and* the registration/file-transfer layer. Set this in production instead of the two granular flags. |
@@ -76,8 +77,273 @@ The frontend is a standard LocalAI instance with distributed mode enabled. These
| `--backend-upgrade-timeout` | `LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT` | `15m` | Same as the install timeout, applied to backend upgrades (force-reinstall). |
| `--model-load-timeout` | `LOCALAI_NATS_MODEL_LOAD_TIMEOUT` | *(derived from checkpoint size)* | Pins the deadline for the `LoadModel` gRPC call the frontend issues to a worker. Leave it unset: by default the deadline is **derived from the checkpoint's on-disk size** (see below), which is what the worker actually spends its load time reading. Set it only to pin a specific budget — the value is then used verbatim, including when it is *shorter* than the derived one, so an operator who wants fast failure gets it. |
| *(env only)* | `LOCALAI_MODEL_LOAD_WAIT` | `60s` | How long an inference request waits for a model that is still cold-loading onto a worker before it is answered with `503`, a `Retry-After` header and live staging progress. The request is served the moment the model becomes ready, so a model already most of the way staged needs no client retry. Set to `0` to wait as long as the load takes — only safe when no ingress or load balancer with an idle timeout sits in front. See [Requests for a model that is still loading](#requests-for-a-model-that-is-still-loading). |
+| `--worker-reconnect-grace` | `LOCALAI_WORKER_RECONNECT_GRACE` | `90s` | How long a worker whose tunnel was lost is treated as **reconnecting** rather than **gone**. Only after this window may the scheduler stop placing work on that worker, clean up its rows and release its models. Set it below the worker's own reconnect worst case and you will condemn workers that are re-homing exactly as designed. Measured on the database clock, so every replica agrees. See [A lost tunnel is a departure, not an absence](#a-lost-tunnel-is-a-departure-not-an-absence). |
| `--expose-node-header` | `LOCALAI_EXPOSE_NODE_HEADER` | `false` | When enabled, inference responses carry an `X-LocalAI-Node` header with the ID of the worker node that served the request. Coverage spans the OpenAI-compatible endpoints (chat completions, completions, embeddings, audio transcriptions, audio speech / TTS, image generations, image inpainting), the Jina rerank endpoint (`/v1/rerank`), the VAD endpoints (`/v1/vad`, `/vad`), and the Anthropic Messages (`/v1/messages`) and Ollama (`/api/chat`, `/api/generate`, `/api/embed`) shims. Useful for debugging, observability and load-balancer attribution. Off by default: the node ID reveals internal cluster topology and should not be exposed on a public endpoint. Best-effort: under heavy concurrency for the same model across multiple replicas, the header may reflect a recent routing decision rather than this exact request's. Acceptable for observability and debugging. |
+### Replica peer links
+
+Frontend replicas record themselves in an `instances` table and open direct links to each other, so that a request arriving at one replica can be served by state another replica holds. Each replica publishes one address for this, and every other replica dials it: it is the address **peers** use, which is not necessarily the address the process binds. A replica behind a Kubernetes Service, a load balancer or a NAT binds one and is reached at another.
+
+When `LOCALAI_DISTRIBUTED_ADVERTISE_ADDR` is unset, the address is derived: LocalAI asks the kernel which local address routes to PostgreSQL, and pairs it with the port it serves on. Every replica reaches the same database, so that address is on a network they demonstrably share.
+
+That only holds while the database is on **another host**. If PostgreSQL runs on the same host or pod (compose, single-node, a sidecar), the route to it is loopback, and advertising a loopback address would send every peer to itself. LocalAI refuses to guess in that case. It starts anyway - refusing would break every single-host deployment, which has no peers to be unreachable by - and logs an error at startup:
+
+```
+ERROR This replica is not registered in the cluster: no advertised address. Peers cannot reach it,
+ and any worker whose tunnel lands here will be unroutable from every other replica
+```
+
+The replica keeps serving every request that reaches it directly. What it cannot do is be reached by another replica, and on a multi-replica deployment that is worse than it sounds: a **worker whose tunnel lands on this replica is unroutable from every other replica**, because the ownership lookup only accepts an owner that is registered and live. This replica serves that worker fine; the others answer requests for it with `no route from this replica to that worker`. Behind a round-robin load balancer with N replicas, that is (N-1)/N of the traffic for that worker.
+
+Because that symptom looks like a broken **worker** and not a misconfigured **frontend**, the replica repeats itself every five minutes for as long as it runs, and names the workers it is currently costing:
+
+```
+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=[node-a node-b] worker_count=2
+```
+
+If you are chasing a worker that answers on one replica and 5xxs on the others, grep the frontend logs for that line before looking at the worker. Until a worker's tunnel lands here the same line appears at `WARN` with no workers named, which is the same misconfiguration not yet costing anything.
+
+A single-replica deployment is unaffected: it has no peers, and it holds every tunnel itself. Set the address explicitly to fix a multi-replica one:
+
+```yaml
+environment:
+ LOCALAI_DISTRIBUTED_ADVERTISE_ADDR: "10.0.1.7:8080" # or the pod IP, service DNS name, etc.
+```
+
+The peer link is served at `/api/cluster/peer` and authenticates with `LOCALAI_REGISTRATION_TOKEN`, the same shared secret workers register with. Replicas that disagree about it cannot link. A replica that stops heartbeating for 30 seconds is dropped from the table by the others, along with the worker-connection rows it owned.
+
+{{% notice note %}}
+**The peer link has no per-replica credential yet.** It checks the shared registration token and takes the replica id in `?id=` on trust. Anything already holding that token - every worker holds it - can therefore open a peer link, relay through it to every worker tunnel a replica owns, by declaring another replica's id displace that replica's inbound link, and hold sessions open against the per-session receive window, which the peer-link code sizes at roughly 31 GiB of unread data per session and which on this route is also a memory budget an attacker can point at one replica. Treat `LOCALAI_REGISTRATION_TOKEN` as a cluster-wide secret with the blast radius of the whole fleet: give it its own value per deployment, do not reuse it elsewhere, and keep `/api/cluster/peer` on a network only your replicas and workers can reach. Per-replica credentials for this route are planned.
+{{% /notice %}}
+
+### Worker tunnels
+
+A worker can open one long-lived, multiplexed tunnel to the frontend instead of listening on a port of its own. It dials `GET /api/cluster/connect?id=`, the connection is upgraded to a WebSocket, and every subsequent request the frontend makes to that worker travels as a stream inside it. Nothing dials *into* the worker, so a worker behind NAT, in another Kubernetes cluster or on a laptop needs no inbound port and no reachable address.
+
+#### Each worker has its own tunnel credential
+
+The dial is authenticated against **that node's own tunnel credential**, which is not the registration token. Registration mints a fresh random secret per node, returns the plaintext once in the registration response as `tunnel_token`, and stores only its SHA-256. So a leaked registration token no longer opens a tunnel: an attacker who has it, and who knows a node ID, still cannot authenticate as that worker.
+
+A worker that presents a credential belonging to no node, or names a node ID the frontend has never seen, is refused with `401` before the WebSocket upgrade happens. A node still awaiting admin approval is refused with `403`. A frontend that cannot read its node table answers `500` rather than `401`, so a worker retries instead of re-registering under a new identity.
+
+**The credential is rotated on every registration.** That follows from storing only the hash: a re-registering worker cannot be told the secret it already holds, so it is given a new one. The worker's live tunnel is unaffected, because the credential is checked when a tunnel is *dialled* and never again; what changes is which secret the next reconnect presents, and the worker learns it in the same response that rotated it.
+
+**A node that has not registered since upgrading cannot tunnel.** Its row has no tunnel credential and the column cannot be back-filled, because the plaintext only ever existed in the response that minted it. Such a node is refused with `401` until it registers again, which a worker restart does. The frontend does *not* fall back to the registration token for these nodes.
+
+Unlike the agent worker's API key and its NATS credential, a tunnel credential **is** issued to a node still awaiting approval. It is inert until then: the tunnel route re-reads the node's status on every dial and refuses a pending one. Withholding it would instead strand workers that register exactly once, since approval on its own prompts no re-registration.
+
+A tunnel credential does not replace `LOCALAI_REGISTRATION_TOKEN`. Without one, node registration itself is unauthenticated, so anyone who can reach the frontend can register a worker and be issued a tunnel credential for it. How far that gets them depends on auto-approve: with auto-approve on the node is healthy at once and the credential works immediately; with it off the node is pending and the credential is inert until an admin approves, so approval is the real gate. LocalAI warns about the missing token at startup.
+
+Only **backend** nodes are issued one. An agent worker has no inbound surface for the tunnel to replace and no client for it, so minting one would widen the credential surface for nothing; its row keeps an empty tunnel credential and the tunnel route refuses it like any other node without one.
+
+The tunnel lands on exactly one frontend replica, and that replica records itself as the owner of the worker's connection in the `node_connections` table. When the socket dies the claim is dropped, but the row stays behind with no owner and a `disconnected_at` stamp, so a worker that is re-dialling the load balancer can be told from one that has never connected. The row is deleted once that departure is older than ten liveness windows (five minutes). If the replica stalls long enough for its peers to reap it, it re-claims the tunnels it still holds on a live session as soon as it re-registers, skipping any whose socket has already gone. That re-claim needs the replica to have an advertised address: without one it never had an instance row to begin with, and its tunnels are usable only by the replica holding them.
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/cluster/connect?id=` | Worker opens its multiplexed tunnel (`Authorization: Bearer `) |
+
+The route is exempt from the normal session/API-key authentication (it authenticates itself, like `/api/cluster/peer`) and is registered in every deployment. Outside distributed mode there is no node table to check a token against, so it answers `503`.
+
+#### What the worker does with the tunnel
+
+The worker holds the tunnel with one goroutine: it dials, serves the frontend's streams until the session dies, and dials again. Every stream opens with a small frame naming which local service it is for, and the worker answers before either side speaks the tunnelled protocol:
+
+| Tag | Goes to | Target |
+|-----|---------|--------|
+| `grpc` | a backend process on this worker | the port; the host is discarded and only `127.0.0.1` is dialled, within the worker's own backend port range |
+| `http` | the worker's own file-transfer and backend-log server | ignored; there is one such server and only the worker knows where it bound |
+
+The `grpc` row is the security boundary of the tunnel, and it is worth being explicit about it. A tunnel terminates inside the worker process, so a stream arriving on it can reach anything the worker can reach; if the frontend could name the host, whoever holds the frontend end could make every worker in the fleet dial arbitrary addresses on its private network. The worker therefore builds the dial address from a constant `127.0.0.1` and a port it has validated, and the string from the wire never reaches the dialler at all. The port range is the one the worker's own allocator hands to backend processes, which by default runs to 65535; setting `LOCALAI_GRPC_MAX_PORT` narrows the allocator and this range together, and a worker with a known backend count should set it.
+
+A stream naming a tag the worker does not serve, a target outside that port range, or a local service it could not reach, is refused with a reason and the stream is **ended** rather than left open. Those refusals are distinct on the wire on purpose: an unknown tag and an out-of-range target are requests this worker will never serve, while an unreachable local service is a backend that has not started yet. A frontend gives up on the first two and may retry the third. One bad stream never affects the others or the session.
+
+Reconnects use exponential backoff with jitter: the interval doubles from 500ms up to a ceiling of 30 seconds, and each wait is drawn between half of that interval and all of it, so no worker ever spins and a fleet that lost the same replica does not come back in lockstep. The interval returns to its floor only after a session that lasted at least 30 seconds. That last part is what stops a rolling frontend restart, where every dial succeeds and then dies moments later, from turning a fleet of workers into a retry storm against the first replica back up. A worker that is refused (`401`, `403`) keeps retrying on the same schedule rather than exiting: a re-registration or an admin approval fixes both without restarting it.
+
+#### What the frontend sends through it
+
+Every connection the frontend makes to a worker now goes through that worker's tunnel. There are four, and all four are the same path underneath:
+
+| What | Protocol | Stream tag |
+|------|----------|-----------|
+| Inference, model load, health checks | gRPC to a backend process | `grpc` |
+| Model file staging, backend-log listing | HTTP to the worker's own server | `http` |
+| Live backend-log streaming | WebSocket to the same server | `http` |
+| The control plane: backend install/upgrade/list/stop/delete, model stop/unload/delete, models running, node stop, and the four object-store staging verbs | HTTP to the worker's own server | `http` |
+
+#### The worker control plane
+
+A serve-backend worker serves the commands the frontend gives it as ordinary
+HTTP routes under `/v1/control/`, on the same loopback server that already
+carries file staging and backend logs, behind the same `LOCALAI_REGISTRATION_TOKEN`
+bearer check. They replace the fourteen `nodes..*` NATS subjects a worker
+used to subscribe to - the ten backend and model lifecycle verbs, plus the four
+object-store staging verbs (`POST /v1/control/files/{ensure,stage,temp,listdir}`,
+mounted only when the deployment configured an object store). The request bodies
+are unchanged and the reply fields keep their names and types, so nothing an
+operator inspects on the wire has a new shape. The one difference is that a
+worker now OMITS an empty reply field where the NATS handlers always emitted it,
+which a client reading a missing field as the zero value cannot tell apart. Agent workers still take `nodes..backend.stop` over NATS.
+
+`files/listdir` is the verb the change is most visible on. Its reply used to be
+sized against what the bus would carry, which put a wide model directory close to
+the limit; it is now a response body the frontend is already reading, so the
+listing is returned whole and nothing truncates it at either end.
+
+Two of the routes stream. `POST /v1/control/backend/install` and
+`/v1/control/backend/upgrade` answer with `application/x-ndjson`: zero or more
+`{"progress":{...}}` lines carrying the same download-progress payload the
+per-op NATS subject carried, followed by exactly one `{"reply":{...}}` line,
+which is always the last line on the body. When the request carries an `op_id`,
+the first progress line has phase `resolving` and is written before any gallery
+work begins, so a cold install that spends minutes on a manifest is
+distinguishable from a stream that is broken. Nothing publishes install progress
+over NATS any more. An install that FAILS is still a
+`200` with a reply whose `success` is `false`. That is deliberate, and it is the
+same distinction the refusal table above draws: a non-2xx means the frontend
+could not get the request to the worker, which nothing may act on, while the
+worker's own verdict, including "there is no such backend", is evidence a reap
+guard may act on. A worker that answered `500` for a failed install would put
+its own verdict in the bucket reserved for a broken link.
+
+A control request carries the caller's deadline and nothing else: the worker
+does not impose a timeout of its own on an install, and a caller that gives up
+cancels the download rather than leaving the worker pulling gigabytes for a
+response nobody will read.
+
+On the frontend's side the ten verbs are ordinary HTTP calls on the `http`
+stream tag, so a control RPC to a worker **another replica holds is relayed
+exactly like an inference request** - same lookup, same one hop, same budget
+arithmetic as [Reaching a worker another replica holds](#reaching-a-worker-another-replica-holds).
+There is nothing to subscribe to before an install: its progress lines share the
+install's own response, so no event can arrive before the caller is listening
+and there is no per-op subject to grant a permission for.
+
+How a control RPC can FAIL is where absence is decided for the whole control
+plane, so the frontend maps every outcome onto exactly one row of the table
+below and never onto another. Only the two rows in which the WORKER spoke may
+be acted on; everything else is this frontend failing to reach it, which says
+nothing about the worker at all:
+
+| What happened | How the frontend reports it | May anything reap on it? |
+|---|---|---|
+| The worker refused the stream with one of its three evidence codes | the refusal itself, unwrapped | **Yes.** The worker spoke. |
+| No route: no live owner, an unreachable peer, no relay path, a refusal code this frontend does not recognise, or the worker saying it learned nothing | "this frontend has no route to that worker" | No |
+| The call ran out of budget | a deadline, which install and upgrade report as *still installing on the worker* | No |
+| `404` under `/v1/control/` | "the worker does not serve that control verb" - it is older than this frontend, and only the upgrade path acts on it, by re-issuing the legacy force-install | No |
+| `200` with a reply whose `error` is set | the worker's own answer, handed to the caller as-is | **Yes**, by the caller |
+
+A `5xx`, or a body the frontend cannot decode, is in the second row and not the
+last: a worker's verdict arrives as a `200`, so a `5xx` is the server failing
+rather than answering.
+
+The address the frontend holds for a backend (the per-replica port a worker reports after an install) is still what identifies it, and it is still what appears in logs and errors. What it no longer is, is somewhere the frontend connects to: it travels inside the tunnel as the stream's target, and the worker decides what to do with it.
+
+A frontend with no way to reach a worker says so and fails. It does **not** fall back to connecting to the worker's advertised address. That fallback is what the tunnel exists to remove, and it is the kind of defect that works on a one-replica developer box and fails in production, so it is an error everywhere. The consequences are deliberately narrow: a model whose worker cannot be reached is not reaped, and its row is left alone, because a frontend that cannot reach a worker has learned nothing about whether that worker is still running the model.
+
+#### Reaching a worker another replica holds
+
+A worker's tunnel lands on exactly one replica, so with N replicas behind a load balancer roughly (N-1)/N of requests arrive somewhere else. Those requests are relayed: the replica that received the request looks up the owner in `node_connections`, **joined against the live `instances` rows**, opens a stream on its peer link to that owner, and the owner splices it onto the worker's tunnel. One hop, never two; a stale ownership row is answered with a routing refusal and the dialling replica resolves the owner again rather than being sent round a loop.
+
+The dialling replica states how much time its own client has left in the frame that opens the relayed stream, and the owner bounds its work by the smaller of that and its own 15s ceiling. Neither number can lengthen the other: a patient client cannot park the owning replica, and an impatient one cannot be kept waiting on a budget it did not ask for.
+
+These outcomes are kept apart on purpose, because they call for different actions:
+
+| Outcome | What it means | What acts on it |
+|---|---|---|
+| No live owner | No replica holds this worker's tunnel | No route right now; the worker's models are **left alone** |
+| Not the owner | The routing was stale | Resolve the owner again |
+| Peer unreachable | A replica exists and will not answer | Retry |
+| No relay path | This replica cannot reach the owner at all | Report; requests here fail until it can |
+| The worker refused | The worker answered and said no | Depends on WHICH refusal; see below |
+
+**None of the first four is absence.** A worker's presence is its **heartbeat**, and a route to it is a separate fact that can be false while the worker is registered, heartbeating and serving every request another replica sends it. So the frontend answers "no route", never "this worker is gone", and none of the first four causes a model to be rescheduled or a `node_models` row to be deleted.
+
+The fifth is different, and deliberately so. A worker that **refuses** a stream has answered, which proves it is connected; what it is refusing is the stream to one backend process on it. That is the ordinary shape of a crashed backend now that workers listen on nothing: the worker's own dial to the process fails and it says so.
+
+There are **four** refusals, and only three of them are evidence about a backend. The distinction decides whether a model's row is deleted, so an operator reading one of these in a log can tell what will happen next:
+
+| Refusal a worker sends | When | Row reaped? |
+|---|---|---|
+| `the worker could not reach the local service for that stream` | The worker's own dial to the backend process was refused. A crashed backend | **Yes.** Reloaded elsewhere, as a dead local backend would be |
+| `the worker does not serve that stream tag` | The worker does not serve that kind of stream at all | **Yes.** Nothing clears this until the worker is upgraded, and the model re-registers somewhere that works |
+| `the worker rejected the stream request as malformed` | The stored backend address is not a port in this worker's range | **Yes.** The row can never be reached, so reaping lets the model re-register a usable address |
+| `the worker could not serve that stream, for a reason that is not about the backend` | The request frame did not arrive in the worker's 15s window, the worker's tunnel was being torn down, or it ran out of a local resource | **No.** These clear on their own; the request fails with "no route" and is retried |
+
+The fourth exists because the other three are acted on. A relayed request crosses a peer link before its frame reaches the worker, so on a congested link a frame can arrive late through nobody's fault; reported as one of the first three, that would evict a model that is loaded and serving. If you see the fourth in your logs, look at peer-link congestion or a worker that is reconnecting, not at the backend it names.
+
+A refusal code the frontend does not recognise - a newer worker's vocabulary - is treated as "no route" as well, so a version skew costs a retry rather than a reaped replica.
+
+That distinction is the whole point rather than a nicety. A scheduler told that a connected worker has gone away stops its backend and reclaims every model it is running, and the events that produce "no route" are ordinary ones: a frontend replica restarting, an ownership row a moment stale, a worker that has not dialled its tunnel yet. Absence has its own two mechanisms and neither of them is a failed request: a stale **heartbeat** (see `--stale-node-threshold`), and a tunnel **departure** older than the reconnect grace (see below).
+
+#### There is no frontend-side fallback
+
+`LOCALAI_WORKER_TUNNEL=false` is a **fatal startup error** on this release. It is not a degraded mode and not a rollback switch: the worker refuses to boot and prints why. Nothing else would be honest, because the setting stops the worker dialling its tunnel while **no frontend path dials a worker's advertised address**, and a worker on this release advertises none and listens on no routable interface, so a worker that started with it off would register, heartbeat, be scheduled onto, and fail every request. The rollback is to run the previous release on both sides.
+
+#### Upgrade the frontends first
+
+**Upgrade every frontend replica, then restart the workers one at a time.**
+
+- **Frontends first (correct).** Old workers keep running, keep heartbeating and keep their `node_models` rows: the new frontend reports them as unroutable rather than as gone, so nothing is rescheduled and nothing is reaped. What fails is requests for models on a worker that has not been restarted yet. That is a real degraded window, but it is bounded by how fast you roll the workers, it heals itself as each one comes back, and no state is lost.
+ - **What you will see while it lasts:** requests for models on a not-yet-restarted worker fail with "no route to the worker", while `GET /api/nodes` still shows that node healthy and heartbeating and its models still listed. Restart the worker and it clears. Nothing needs fixing; you are watching the window close.
+- **Workers first (this fails, do not do it).** An old frontend has no `/api/cluster/connect` route for the worker to dial *and* rejects the new worker's registration outright, because the worker no longer sends an address and the old frontend requires one. A 4xx is a verdict rather than an outage, so the worker reports the reason on the **first** attempt and exits instead of retrying. Every worker you restart is a worker you take out of the fleet until the frontends are upgraded.
+ - **What you will see if you do it anyway:** each restarted worker exits within a second or two of starting, with
+
+ ```
+ registration failed with status 400: {"error":{"code":400,"message":"address is required for backend workers","type":"node_error"}}: the frontend refused this registration
+ ```
+
+ The fleet drains one node per restart, and the nodes that are left are the ones you have not touched yet. Grep for `address is required for backend workers` if your log collector reflows the line.
+
+A worker that cannot reach its frontend *at the network level* retries with exponential backoff and never gives up, so restarting a worker is all that is needed to close the frontend-first window. A worker whose registration is **rejected** does not retry, which is what makes the wrong order destructive rather than slow.
+
+##### Rolling a frontend back requires restarting every worker
+
+Registering against an upgraded frontend **clears** a node's `address` and `http_address` columns in the shared database, and re-registration is the only thing that ever writes them back. So a partial rollback does not restore the previous behaviour on its own: the old frontend code reads an empty address for every node that has registered since the upgrade and dials nothing. Roll the frontends back *and then restart every worker* so each one re-registers and repopulates its address. Rolling back is not a frontend-only operation.
+
+#### Workers bind nothing routable
+
+A worker on this release opens **no inbound listener on a routable interface**. Its backend gRPC processes and its HTTP file-transfer server all bind loopback, and the frontend reaches both through the tunnel. Concretely:
+
+- **No inbound firewall rule, published port, Service or Ingress is needed for a worker.** A serve-backend worker needs outbound access to the frontend URL (`LOCALAI_REGISTER_TO`), and nothing else - not even to NATS. An agent worker also needs outbound access to `LOCALAI_NATS_URL`.
+- **`LOCALAI_ADVERTISE_ADDR` and `LOCALAI_ADVERTISE_HTTP_ADDR` are gone.** There is nothing to advertise. Both are ignored if still set; remove them.
+- **`LOCALAI_ADDR` and `LOCALAI_SERVE_ADDR` are read for their port only.** The port is the base of the backend port range, and `port-1` is the HTTP file-transfer port. The host half names an interface nothing binds.
+- The node's `address` and `http_address` fields in `GET /api/nodes` are empty, and are cleared for nodes that reported them before the upgrade.
+
+#### A lost tunnel is a departure, not an absence
+
+When a worker's tunnel goes, the frontend does **not** forget the worker. It records *when* the tunnel went, and for a grace period after that the worker is reported as **reconnecting**, not as gone. Only once the departure is older than the grace may anything act on the worker's absence: stop scheduling work onto it, clean up its rows, release its models.
+
+That distinction exists because a worker loses its tunnel for entirely ordinary reasons. A frontend replica restarting during a rolling upgrade drops every tunnel it held, and each of those workers immediately re-dials the load balancer and lands on another replica. Treating that as "the worker is gone" would evict models mid-upgrade for a fleet that never actually went anywhere.
+
+Three things read this. The **scheduler** reads it before it places a cold load: a worker whose departure has outlived the grace is skipped and marked unhealthy, so every other frontend replica stops choosing it too. **LRU eviction** reads it before it hands back the node it freed capacity on, because a node full enough to be an eviction target is exactly the node the placement selectors never offer, so the scheduler's own check never sees it. The **health monitor** reads it on every cycle, which is what covers the case the heartbeat cannot see. A worker's heartbeat says its supervisor is alive; it says nothing about whether anything here can reach that worker's backends, because those are reached over the tunnel. A worker that heartbeats with a permanently dead tunnel (a proxy that stopped upgrading WebSockets, a rotated registration credential, a reconnect loop longer than the grace) is therefore marked unhealthy too, rather than staying listed healthy while every request for a model loaded on it fails "no route to that worker".
+
+The log lines are `Scheduled node has no tunnel and its departure outlived the reconnect grace, marking unhealthy and re-scheduling`, `Eviction target has no tunnel and its departure outlived the reconnect grace, marking unhealthy and evicting again`, and `Node is heartbeating but its tunnel has been gone longer than the reconnect grace; marking unhealthy`. Each names the grace it used.
+
+**The demotion is a status change, not a deletion.** The worker's `node_models` rows survive it. Status is enough to unwedge the node, because request routing and LRU eviction both select only healthy nodes, so the model stops being served from there and the next request places it somewhere reachable. Deleting rows on a presence read would give any future defect in that read the widest possible blast radius, for nothing the demotion does not already deliver.
+
+Status is a *trailing* signal, though: it is only as fresh as the last health cycle. That is why the two paths that are about to commit work to a node, cold-load placement and eviction, read presence directly instead of trusting the status column. Everything else reads the column.
+
+**A returning heartbeat does not promote a node back on its own.** Recovery needs the *tunnel* back, not just the supervisor: the health monitor re-promotes a demoted node only once presence reports it connected or reconnecting again. Before that check the two were conflated, and a heartbeating worker with a dead tunnel was promoted back to healthy on the next 15s cycle, every cycle.
+
+The other three answers place work as normal. **Reconnecting** (the tunnel went inside the grace) and **unknown** (no connection row at all: the worker has never dialled, or its departure has already aged out of retention) are both non-verdicts; so is a failure to read the answer, because a database hiccup that excluded workers would cost the fleet its capacity for a reason that has nothing to do with any worker. In each of those cases scheduling proceeds, the node keeps its status, and the install that follows reports its own outcome.
+
+A frontend refuses to start if either reader was built without a source of absence, because that failure has no other symptom: it looks exactly like a fleet that is fine.
+
+| Flag | Env var | Default | Description |
+|------|---------|---------|-------------|
+| `--worker-reconnect-grace` | `LOCALAI_WORKER_RECONNECT_GRACE` | `90s` | How long a worker whose tunnel was lost is treated as reconnecting rather than gone. |
+
+The default clears the worker's own worst-case reconnect. A worker retries with exponential backoff capped at **30s**, and each attempt has a **10s** dial budget, so a worker that waits the ceiling, hangs a dial, and waits the ceiling again is back at **70s**. The backoff also only resets after a session that lasted 30s, which a replica accepting a dial and then dying denies, so a worker crossing a rolling restart really does climb to the ceiling rather than sitting near the 500ms floor. 90s clears that worst case with margin; 60s would sit under it.
+
+What you trade by changing it:
+
+- **Lower**: a worker that has genuinely gone away is declared absent sooner, so its rows are cleaned and its models released sooner. Set it below the worker's backoff ceiling and you will condemn workers that are re-homing exactly as designed.
+- **Higher**: a rolling frontend restart is safer, because workers crossing it stay "reconnecting" for longer. The cost is that a worker that really has died keeps its rows for longer.
+
+The window is measured on the **database clock**, not on any frontend's own clock, so every replica agrees to the second on when a worker's grace ran out. A departure row is kept well past the grace before it is purged; once purged, the worker reads as *unknown* rather than *gone*, and nothing acts on unknown.
+
### The model load deadline scales with the checkpoint
The `LoadModel` deadline starts *after* the backend is installed and the model files are staged, so it covers only the worker backend's own checkpoint read and pipeline init. That work is proportional to the bytes on disk, which makes any fixed deadline a model-size cliff rather than a timeout: a 70 GB video checkpoint on a Jetson Thor worker failed reproducibly against the old fixed 5m default (`rpc error: code = DeadlineExceeded` after 953.5s of wall clock, roughly 11m of which was backend install and staging), and simply raising the constant would only move the cliff to the next larger model while making a genuinely wedged *small* model hang for the whole inflated duration.
@@ -156,7 +422,9 @@ A frontend replica that dies mid-load does not wedge the model: the job row carr
### NATS JWT authentication (recommended for production)
-By default, NATS connections are anonymous: any client that can reach port `4222` may publish control-plane subjects such as `nodes..backend.install`. Enable JWT auth to scope workers to their own node subjects and give the frontend a dedicated service credential.
+**This section is about agent workers and the frontend.** A serve-backend worker opens no NATS connection, so none of it applies to one; its own credential is the tunnel token it gets at registration, and its control plane is authenticated by `LOCALAI_REGISTRATION_TOKEN`.
+
+By default, NATS connections are anonymous: any client that can reach port `4222` may publish the subjects still carried on it. Those are the agent-worker job subjects, MCP, and the frontend's own cross-replica events. `nodes..backend.install` and its nine siblings are **not** among them - they are HTTP routes on the worker's tunnel, see [The worker control plane](#the-worker-control-plane). Enable JWT auth to scope agent workers to their own subjects and give the frontend a dedicated service credential.
| Flag | Env Var | Description |
|------|---------|-------------|
@@ -188,9 +456,9 @@ The same env vars apply to backend workers and `local-ai agent-worker`. If the s
}
```
-Workers connect with that JWT and seed automatically (shown once; store securely). Override with `LOCALAI_NATS_JWT` / `LOCALAI_NATS_USER_SEED` if needed. Set `LOCALAI_NATS_REQUIRE_AUTH=true` on workers when the bus requires credentials.
+Agent workers connect with that JWT and seed automatically (shown once; store securely). Override with `LOCALAI_NATS_JWT` / `LOCALAI_NATS_USER_SEED` if needed. Set `LOCALAI_NATS_REQUIRE_AUTH=true` on an agent worker when the bus requires credentials; `local-ai worker` has no such flag, because it opens no connection to require credentials for. A JWT is still minted for a serve-backend node at registration and is simply unused; it grants nothing but that connection's own reply inbox.
-When `LOCALAI_NATS_REQUIRE_AUTH=true` and no static credentials are provided, a worker that registers while still **pending admin approval** keeps re-registering (with backoff) until an admin approves it and the frontend mints its JWT - it does not start unauthenticated. This retry is **bounded**: if the node is never approved (or no credentials are minted) after a large number of attempts, the worker exits non-zero so the failure is visible (a crash-looping or failed worker) rather than hanging silently. Minted worker JWTs are also **refreshed automatically** before they expire (the worker re-registers at ~75% of the JWT lifetime), so long-running workers survive past `LOCALAI_NATS_WORKER_JWT_TTL`; the NATS connection picks up the new JWT on its next reconnect. If refresh fails persistently, the worker exits (to restart and re-acquire) rather than drifting toward an expired, unrenewable JWT. Statically configured (`LOCALAI_NATS_JWT`) and service (`LOCALAI_NATS_SERVICE_JWT`) credentials are used as-is and not refreshed.
+When `LOCALAI_NATS_REQUIRE_AUTH=true` and no static credentials are provided, an agent worker that registers while still **pending admin approval** keeps re-registering (with backoff) until an admin approves it and the frontend mints its JWT - it does not start unauthenticated. This retry is **bounded**: if the node is never approved (or no credentials are minted) after a large number of attempts, the worker exits non-zero so the failure is visible (a crash-looping or failed worker) rather than hanging silently. Minted worker JWTs are also **refreshed automatically** before they expire (the worker re-registers at ~75% of the JWT lifetime), so long-running workers survive past `LOCALAI_NATS_WORKER_JWT_TTL`; the NATS connection picks up the new JWT on its next reconnect. If refresh fails persistently, the worker exits (to restart and re-acquire) rather than drifting toward an expired, unrenewable JWT. Statically configured (`LOCALAI_NATS_JWT`) and service (`LOCALAI_NATS_SERVICE_JWT`) credentials are used as-is and not refreshed.
Generate operator/account material with [`scripts/nats-auth-setup.sh`](https://github.com/mudler/LocalAI/blob/master/scripts/nats-auth-setup.sh) (requires [nsc](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/nsc)). Configure the NATS server with account resolver JWTs before enabling `LOCALAI_NATS_REQUIRE_AUTH`.
@@ -200,7 +468,7 @@ Generate operator/account material with [`scripts/nats-auth-setup.sh`](https://g
### Optional: S3 Object Storage
-For multi-host deployments where workers don't share a filesystem, S3-compatible storage enables distributed file transfer (model files, configs):
+For multi-host deployments where workers don't share a filesystem, S3-compatible storage enables distributed file transfer (model files, configs). The frontend uploads the file to the bucket and then tells the worker to fetch it, over that worker's tunnel (`POST /v1/control/files/ensure`); the reverse direction (`.../files/stage`) has the worker upload one of its own files for the frontend to pull down. The bytes travel through the bucket, never through the tunnel:
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
@@ -210,6 +478,8 @@ For multi-host deployments where workers don't share a filesystem, S3-compatible
| `--storage-access-key` | `LOCALAI_STORAGE_ACCESS_KEY` | *(empty)* | S3 access key |
| `--storage-secret-key` | `LOCALAI_STORAGE_SECRET_KEY` | *(empty)* | S3 secret key |
+A worker started without `LOCALAI_STORAGE_URL` does not serve the four staging verbs at all, and answers `404` for them, which is the same answer a frontend gets from a worker too old to know them.
+
When S3 is not configured, model files are transferred directly from the frontend to workers via **HTTP** - no shared filesystem needed. Each worker runs a small HTTP file transfer server alongside the gRPC backend process. This is the default and works out of the box.
For high-throughput or very large model files, S3 can be more efficient since it avoids streaming through the frontend.
@@ -231,7 +501,10 @@ Hugging Face for managed artifacts.
With `LOCALAI_DISTRIBUTED_SHARED_MODELS` enabled, workers use the shared
absolute snapshot path and skip transfer. Otherwise, the controller stages the
-complete snapshot tree to each worker before loading the backend.
+complete snapshot tree to each worker before loading the backend. With an object
+store configured the controller uploads to the bucket and commands the worker to
+fetch over its tunnel; without one it pushes the files to the worker's HTTP file
+transfer server directly. Neither path uses NATS.
{{% notice warning %}}
Every controller and worker must have enough disk space for its own snapshot
@@ -240,7 +513,9 @@ during installation as well as the committed snapshot.
{{% /notice %}}
{{% notice warning %}}
-The worker HTTP file transfer server is authenticated by `LOCALAI_REGISTRATION_TOKEN`. If the token is **empty**, the server **fails open** - anyone who can reach the port gets read/write access to the worker's models/staging/data directories (a remote model-poisoning / exfiltration vector). The worker logs a loud warning at startup in this case. Always set `LOCALAI_REGISTRATION_TOKEN` in distributed mode, and set `LOCALAI_DISTRIBUTED_REQUIRE_AUTH=true` (frontend **and** workers) to make a missing token *or* missing NATS credentials a hard startup error rather than a silent fail-open. Firewall the file-transfer port (gRPC base − 1) so only the frontend can reach it.
+The worker HTTP file transfer server is authenticated by `LOCALAI_REGISTRATION_TOKEN`. If the token is **empty**, the server **fails open** - anyone who can reach the port gets read/write access to the worker's models/staging/data directories (a remote model-poisoning / exfiltration vector), **and to the `/v1/control/` routes that install, upgrade and delete backends and stop the node**. The worker logs a loud warning at startup in this case. Always set `LOCALAI_REGISTRATION_TOKEN` in distributed mode, and set `LOCALAI_DISTRIBUTED_REQUIRE_AUTH=true` (frontend **and** workers) to make a missing token a hard startup error rather than a silent fail-open. On the frontend and on agent workers it also makes missing NATS credentials fatal; on a serve-backend worker it means the registration token alone, since that worker uses no bus credential.
+
+By default the server binds loopback, so "anyone who can reach the port" means a process on the worker host, and no firewall rule is required. Setting `LOCALAI_HTTP_ADDR` to a routable address opts back out of that and puts the fail-open case back on the network - if you do it, firewall the port.
{{% /notice %}}
### Watching Backend Installs
@@ -284,79 +559,70 @@ Workers are started with the `worker` subcommand. Each worker is generic - it do
```bash
local-ai worker \
--register-to http://frontend:8080 \
- --registration-token changeme \
- --nats-url nats://nats:4222
+ --registration-token changeme
```
+There is no `--nats-url` here. A serve-backend worker connects to no message bus: it dials one outbound tunnel to `--register-to` and serves every request the frontend makes of it over that. The flag is still accepted and ignored, so an existing command line keeps working.
+
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
-| `--addr` | `LOCALAI_SERVE_ADDR` | `0.0.0.0:50051` | gRPC listen address |
+| `--addr` | `LOCALAI_ADDR` | *(unset)* | Base port for backend gRPC processes. Only the port is used; nothing binds the host |
+| `--serve-addr` | `LOCALAI_SERVE_ADDR` | `0.0.0.0:50051` | Same, used when `--addr` is unset |
| `--grpc-max-port` | `LOCALAI_GRPC_MAX_PORT` | `65535` | Highest port the worker may assign to a backend gRPC process. Each backend gets its own port, allocated upward from the base port, so the width of `[base port, this]` caps how many backends this worker can run at once (see [Backend gRPC port range](#backend-grpc-port-range)) |
-| `--advertise-addr` | `LOCALAI_ADVERTISE_ADDR` | *(auto)* | Address the frontend uses to reach this node (see below) |
-| `--http-addr` | `LOCALAI_HTTP_ADDR` | gRPC port - 1 | HTTP file transfer server bind address |
-| `--advertise-http-addr` | `LOCALAI_ADVERTISE_HTTP_ADDR` | *(auto)* | HTTP address the frontend uses for file transfer |
+| `--http-addr` | `LOCALAI_HTTP_ADDR` | `127.0.0.1:{gRPC port - 1}` | HTTP file transfer server bind address |
| `--register-to` | `LOCALAI_REGISTER_TO` | *(required)* | Frontend URL for self-registration |
| `--node-name` | `LOCALAI_NODE_NAME` | hostname | Human-readable node name |
| `--registration-token` | `LOCALAI_REGISTRATION_TOKEN` | *(empty)* | Token to authenticate with the frontend |
| `--registration-require-auth` | `LOCALAI_REGISTRATION_REQUIRE_AUTH` | `false` | Refuse to start the HTTP file-transfer server when no registration token is set (it would otherwise fail open) |
-| `--distributed-require-auth` | `LOCALAI_DISTRIBUTED_REQUIRE_AUTH` | `false` | Umbrella switch implying both `--registration-require-auth` and `--nats-require-auth` |
+| `--distributed-require-auth` | `LOCALAI_DISTRIBUTED_REQUIRE_AUTH` | `false` | Umbrella switch implying `--registration-require-auth` |
| `--heartbeat-interval` | `LOCALAI_HEARTBEAT_INTERVAL` | `10s` | Interval between heartbeat pings |
-| `--nats-url` | `LOCALAI_NATS_URL` | *(required)* | NATS URL for backend installation and file staging |
-| `--nats-jwt` | `LOCALAI_NATS_JWT` | *(empty)* | Optional override for the `nats_jwt` returned at registration |
-| `--nats-user-seed` | `LOCALAI_NATS_USER_SEED` | *(empty)* | Optional override for `nats_user_seed` from registration |
-| `--nats-require-auth` | `LOCALAI_NATS_REQUIRE_AUTH` | `false` | Require NATS JWT+seed (from registration or env) |
-| `--nats-tls-ca` | `LOCALAI_NATS_TLS_CA` | *(empty)* | PEM file for NATS server CA |
-| `--nats-tls-cert` | `LOCALAI_NATS_TLS_CERT` | *(empty)* | Client certificate for NATS mTLS |
-| `--nats-tls-key` | `LOCALAI_NATS_TLS_KEY` | *(empty)* | Client private key for NATS mTLS |
+| `--worker-tunnel` | `LOCALAI_WORKER_TUNNEL` | `true` | Hold one outbound multiplexed tunnel to the frontend and serve its requests over it, so this worker needs no inbound port (see [Worker tunnels](#worker-tunnels)). Setting it to `false` is a **fatal startup error**, not a degraded mode: the frontend has no path that dials a worker's advertised address, so a worker without its tunnel is a worker nothing can reach. To run without tunnels, run the pre-tunnel release on both the worker and the frontend. |
+| `--nats-url` | `LOCALAI_NATS_URL` | *(ignored)* | **Accepted and ignored.** A serve-backend worker opens no NATS connection. Kept so an existing worker command line still starts. |
| `--backends-path` | `LOCALAI_BACKENDS_PATH` | `./backends` | Path to backend binaries |
| `--models-path` | `LOCALAI_MODELS_PATH` | `./models` | Path to model files |
| `--vram-budget` | `LOCALAI_VRAM_BUDGET` | *(empty)* | Cap the VRAM this node advertises for model placement, as a percentage (e.g. `80%`) or an absolute amount (e.g. `12GB`). Empty uses all detected VRAM. See [Per-node VRAM budget](#per-node-vram-budget). |
{{% notice tip %}}
-**Advertise address:** The `--addr` flag is the local bind address for gRPC. The `--advertise-addr` is the address the frontend stores and uses to reach the worker via gRPC. If not set, the worker auto-derives it by replacing `0.0.0.0` with the OS hostname (which in Docker is the container ID, resolvable via Docker DNS). Set `--advertise-addr` explicitly when the auto-detected hostname is not routable from the frontend (e.g., in Kubernetes, use the pod's service DNS name).
+**There is no advertise address.** A worker states no endpoint at registration and binds nothing routable; the frontend reaches it through the tunnel it dials. `--advertise-addr` and `--advertise-http-addr` no longer exist. `--addr` and `--http-addr` remain, and set where the worker listens **locally**: only the port of `--addr` is used, and `--http-addr` binds loopback by default.
-**HTTP file transfer:** Each worker also runs a small HTTP server for file transfer (model files, configs). By default it listens on the gRPC base port - 1 (e.g., if gRPC base is 50051, HTTP is on 50050). gRPC ports grow upward from the base port as additional models are loaded. Set `--advertise-http-addr` if the auto-detected address is not routable from the frontend.
+**HTTP file transfer:** Each worker also runs a small HTTP server for file transfer (model files, configs). It listens on loopback at the gRPC base port - 1 (e.g., if gRPC base is 50051, HTTP is on 50050). gRPC ports grow upward from the base port as additional models are loaded.
{{% /notice %}}
### Worker Health Probes
-The worker's HTTP server (base port - 1, default 50050) exposes two unauthenticated probes:
+The worker's HTTP server (loopback, base port - 1, default 50050) exposes two unauthenticated probes. They are reachable from the worker host - which is where a container healthcheck runs - and not from the network:
| Endpoint | Meaning |
|----------|---------|
-| `/healthz` | **Liveness.** 200 whenever the process is up and serving. Deliberately independent of readiness, so a brief NATS outage does not trigger a restart storm across every worker. |
-| `/readyz` | **Readiness.** 200 only when the worker is registered *and* its NATS connection is live; 503 otherwise. |
+| `/healthz` | **Liveness.** 200 whenever the process is up and serving. Deliberately independent of readiness, so a frontend restart that drops every tunnel does not trigger a restart storm across every worker. |
+| `/readyz` | **Readiness.** 200 only when the worker is registered *and* it currently holds a tunnel session; 503 otherwise. |
-`/readyz` reports something the frontend cannot see on its own. The node registry's `status` and `last_heartbeat` are driven by an HTTP heartbeat to the frontend, which is a different network path from NATS — a worker can keep heartbeating while its NATS link is dead, and so appear `healthy` in the registry while being unable to receive any work. The local probe closes that gap.
+`/readyz` tracks the **tunnel**, because that is the only way anything reaches this worker: it binds loopback, advertises no address, and every request the frontend makes of it arrives as a stream inside that tunnel. It reports something the local supervisor cannot see on its own. The node registry's `status` and `last_heartbeat` are driven by an HTTP heartbeat to the frontend, a different network path - a worker can keep heartbeating while its tunnel is dead, and so appear `healthy` in the registry while being unreachable. The local probe closes that gap.
-The container image's `HEALTHCHECK` detects worker mode and probes this endpoint automatically; no `HEALTHCHECK_ENDPOINT` override is needed. Set `HEALTHCHECK_ENDPOINT` only to pin an explicit URL.
+A 503 here is **this container's own report that it cannot serve right now**, and nothing else. It is not a claim that the worker is gone; the frontend decides that from the tunnel session it holds, aged against `LOCALAI_WORKER_RECONNECT_GRACE`. The worker keeps heartbeating throughout a tunnel outage for exactly that reason: withholding the heartbeat would report an unreachable worker as an absent one, on the one path that has no grace.
-### Worker Address Configuration
+The container image's `HEALTHCHECK` detects worker mode and probes this endpoint automatically, deriving the port from `LOCALAI_HTTP_ADDR`, else `LOCALAI_ADDR`, else `LOCALAI_SERVE_ADDR`, minus one - the same order the worker itself uses. No `HEALTHCHECK_ENDPOINT` override is needed. Set `HEALTHCHECK_ENDPOINT` only when the bind address is passed as a CLI flag rather than an environment variable, or to pin an explicit URL.
-The simplest way to configure a worker's network address is with a single variable:
+### Worker Port Configuration
-| Variable | Description |
-|----------|-------------|
-| `LOCALAI_ADDR` | Reachable address of this worker (`host:port`). The port is used as the base for gRPC backend processes, and `port-1` for the HTTP file transfer server. |
+A worker needs no address configuration at all. It binds only loopback and reaches the frontend outbound, so the defaults work behind NAT, in another cluster, or on a laptop:
-**Example:**
```yaml
environment:
- LOCALAI_ADDR: "192.168.1.100:50051"
- LOCALAI_NATS_URL: "nats://frontend:4222"
LOCALAI_REGISTER_TO: "http://frontend:8080"
LOCALAI_REGISTRATION_TOKEN: "my-secret"
```
-For advanced networking scenarios (NAT, load balancers, separate gRPC/HTTP ports), the following override variables are available:
+Set the variables below only to move the worker's **local** port range - for example when two workers share a host, or when the default range collides with something else. Only the port of each is used; the host half names an interface nothing binds.
| Variable | Description | Default |
|----------|-------------|---------|
-| `LOCALAI_SERVE_ADDR` | gRPC base port bind address | `0.0.0.0:50051` |
+| `LOCALAI_ADDR` | Base port for backend gRPC processes, as `host:port`. `port-1` is the HTTP file-transfer port | *(unset; falls back to `LOCALAI_SERVE_ADDR`)* |
+| `LOCALAI_SERVE_ADDR` | Base port, as above, when `LOCALAI_ADDR` is unset | `0.0.0.0:50051` |
| `LOCALAI_GRPC_MAX_PORT` | Highest port assignable to a backend gRPC process | `65535` |
-| `LOCALAI_HTTP_ADDR` | HTTP file transfer bind address | `0.0.0.0:{gRPC port - 1}` |
-| `LOCALAI_ADVERTISE_ADDR` | Public gRPC address (if different from `LOCALAI_ADDR`) | Derived from `LOCALAI_ADDR` |
-| `LOCALAI_ADVERTISE_HTTP_ADDR` | Public HTTP address (if different from gRPC host) | Derived from advertise host + HTTP port |
+| `LOCALAI_HTTP_ADDR` | HTTP file transfer bind address. Bound exactly as given, so this is also the way to expose that server deliberately | `127.0.0.1:{base port - 1}` |
+
+`LOCALAI_ADVERTISE_ADDR` and `LOCALAI_ADVERTISE_HTTP_ADDR` no longer exist. They named the endpoint the frontend dialled; nothing dials a worker any more. Remove them.
### Backend gRPC port range
@@ -437,7 +703,7 @@ The system automatically applies hardware-detected labels on registration:
### How Workers Operate
-Workers start as generic processes with no backend installed. When the SmartRouter needs to load a model on a worker, it sends a NATS `backend.install` event with the backend name and model ID. The worker:
+Workers start as generic processes with no backend installed. When the SmartRouter needs to load a model on a worker, it calls `POST /v1/control/backend/install` through that worker's tunnel with the backend name and model ID. The worker:
1. Installs the backend from the gallery (if not already installed)
2. Starts a **new gRPC backend process on a dynamic port** (each model gets its own process)
@@ -464,6 +730,8 @@ Used by workers themselves (registration, heartbeat, etc.). Authenticated via th
| `GET` | `/api/node/:id/models` | Query own loaded models |
| `DELETE` | `/api/node/:id` | Deregister self |
+The worker tunnel at `GET /api/cluster/connect` is also worker-facing but is authenticated differently: against the node's own stored token rather than the shared registration token. See [Worker tunnels](#worker-tunnels).
+
### `/api/nodes/` - Admin management
Used by the WebUI and admin API consumers. Requires admin authentication.
@@ -536,7 +804,7 @@ The edit response includes these fields:
- `config_revision` identifies the saved semantic configuration.
- `pending_cleanup` counts old replicas that still need cleanup when the response returns.
-LocalAI sends an acknowledged stop request for each exact backend process. If a worker or NATS is unreachable, LocalAI keeps the replica in the `unloading` state and retries with durable backoff. The saved edit remains successful while cleanup is pending.
+LocalAI sends an acknowledged stop request for each exact backend process, over that worker's tunnel. If the worker is unreachable, LocalAI keeps the replica in the `unloading` state and retries with durable backoff. The saved edit remains successful while cleanup is pending.
Workers must support the exact model-stop protocol. Upgrade all workers before you rely on revision cleanup. An older worker cannot acknowledge the request, so its stale replica remains `unloading` until cleanup succeeds or the worker re-registers.
@@ -844,13 +1112,11 @@ ds4 layer-split inference is **manual setup** in this release (Phase 1): you pla
local-ai worker \
--register-to http://frontend:8080 \
--node-name worker-2 \
- --nats-url nats://nats:4222 \
--registration-token changeme
local-ai worker \
--register-to http://frontend:8080 \
--node-name worker-3 \
- --nats-url nats://nats:4222 \
--registration-token changeme
```
@@ -1057,12 +1323,12 @@ Notes:
|---|---|---|
| **Discovery** | Automatic via libp2p token | Self-registration to frontend URL |
| **State storage** | In-memory / ledger | PostgreSQL |
-| **Coordination** | Gossip protocol | NATS messaging |
+| **Coordination** | Gossip protocol | The worker's own tunnel for serve-backend work; NATS for agent workers and cross-replica frontend events |
| **Node management** | Automatic | REST API + WebUI |
| **Health monitoring** | Peer heartbeats | Centralized HealthMonitor |
-| **Backend management** | Manual per node | Dynamic via NATS backend.install |
+| **Backend management** | Manual per node | Dynamic via the worker's `backend.install` control route |
| **Best for** | Ad-hoc clusters, community sharing | Production, Kubernetes, managed infrastructure |
-| **Setup complexity** | Minimal (share a token) | Requires PostgreSQL + NATS |
+| **Setup complexity** | Minimal (share a token) | Requires PostgreSQL on the frontend, plus NATS if you run agent workers. Serve-backend workers need neither: only an outbound route to the frontend URL. |
## Troubleshooting
@@ -1072,8 +1338,9 @@ Notes:
- Ensure auth is enabled on the frontend (`LOCALAI_AUTH=true`)
**NATS connection errors:**
+- These concern the **frontend** and **agent workers** only. A `local-ai worker` opens no NATS connection; if one is failing to join, look at its tunnel and its `--register-to` instead.
- Confirm NATS is running and reachable (`nats-server --signal ldm` or check port 4222)
-- Check that `--nats-url` uses the correct hostname/IP from the worker's network perspective
+- Check that `--nats-url` uses the correct hostname/IP from that component's network perspective
**PostgreSQL connection errors:**
- Verify the connection URL format: `postgresql://user:password@host:5432/dbname?sslmode=disable`
@@ -1093,7 +1360,7 @@ Notes:
- Confirm that every routable replica has `state: loaded` and the same current `config_revision`.
- Treat a different `effective_options_hash` as diagnostic information. Node-specific defaults can cause valid differences.
- Check `cleanup_error` and `cleanup_next_retry_at` on replicas in the `unloading` state.
-- Check connectivity to the worker and NATS when cleanup reports a timeout or no responder.
+- Check that the worker's tunnel is up when cleanup reports a timeout or no route.
- Upgrade the worker when it does not support the exact model-stop request.
- Stop and restart the stale backend only as an operational recovery action. LocalAI keeps it non-routable while durable cleanup is pending.
@@ -1103,11 +1370,12 @@ Notes:
- Liveness is decided by the load job's progress heartbeat, not by elapsed time. Staging a large checkpoint legitimately runs for a long time without touching the replica row, so a transfer that is still progressing is never reclaimed however long it takes.
- `Reconciler: reclaimed a replica slot held by a load nobody is driving` names each row reclaimed this way.
-**A request fails with `nats: no responders available for request`:**
-- The chosen worker was not subscribed on the bus when the frontend tried to install the backend on it. A node's status comes from its HTTP heartbeat, which is a separate channel: a worker that stops stays `healthy` until that heartbeat ages out.
-- The scheduler now checks that a node still answers on the bus before it commits to it, marks one that does not as unhealthy, and picks another. A request should therefore see this only when no reachable node is left.
-- Only a no-responders answer counts as absent. A worker that answers slowly stays eligible, because excluding it would cost capacity that is really there.
-- Check the worker process is running and its NATS connection is up. `Scheduled node is not answering on the bus` in the frontend log names each node demoted this way.
+**A request fails with `this frontend has no route to that worker`:**
+- The chosen worker's tunnel was not reachable from the replica that handled the request. A node's status comes from its HTTP heartbeat, which is a separate channel: a worker that stops stays `healthy` until that heartbeat ages out, and a worker that is very much alive can be unroutable for a moment while its tunnel re-homes between frontend replicas.
+- It is **not** the same as the worker being gone, and nothing acts on it as if it were. A model on an unroutable worker is not reaped, its rows are left alone, and the node is not demoted: doing any of those on a lost route is how a rolling frontend restart turns into a fleet-wide eviction.
+- Check the worker process is running and that it has an open tunnel (`opened a tunnelled stream to a worker` in the frontend log, and the worker's own dial/reconnect lines). A worker behind a load balancer that keeps reconnecting is usually an idle-timeout or WebSocket-upgrade problem at the proxy; see the tunnel section above.
+- **`no route` is not `gone`, and nothing in the frontend reads it as such.** A worker is declared **gone** by one mechanism only: no live frontend replica holds its tunnel *and* its departure is older than `--worker-reconnect-grace`. That is a fact recorded in the shared database, so every replica answers it identically. "No route" is one replica failing to reach a worker right now, and it is not evidence about the worker at all.
+- Older releases decided absence from `nats: no responders available for request`, which was one frontend's observation that nobody answered *it* within a request budget. Two replicas asking at the same moment could disagree and demote each other's workers. That signal is gone from the scheduler; if you still see the message, it concerns only the subjects that remain on the bus (agent-worker jobs and MCP), never a serve-backend worker.
**A worker fills its own disk over time:**
- A request that carries a file (an image, an audio clip, a video) stages that file to the worker under `/../staging/ephemeral/`. The worker deletes these 6 hours after the request that needed them, and sweeps every 30 minutes plus once at startup, so a worker that crashed mid-request still reclaims the space.
@@ -1125,9 +1393,9 @@ Notes:
**Port conflicts on workers:**
- Each model gets its own gRPC process on an incrementing port (50051, 50052, ...)
- The HTTP file transfer server runs on the base port - 1 (default: 50050)
-- Ensure the port range is not blocked by firewalls or used by other services
+- All of those bind loopback, so a firewall cannot be the cause. What can is another service on the same host already holding a port in the range: move the worker's range with `LOCALAI_ADDR` (see [Worker Port Configuration](#worker-port-configuration)) or bound it with `LOCALAI_GRPC_MAX_PORT`
- Verify the backend gallery configuration is correct
-- The worker needs network access to download backends from the gallery
+- The worker needs OUTBOUND network access to the gallery and to `LOCALAI_REGISTER_TO`. It needs no inbound access at all, and no access to NATS
## Roadmap: Routing and Caching Enhancements
diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md
index fb0872545e23..4e16c7c965b5 100644
--- a/docs/content/reference/cli-reference.md
+++ b/docs/content/reference/cli-reference.md
@@ -209,9 +209,9 @@ LocalAI supports several subcommands beyond `run`:
- `local-ai transcript` - Convert audio to text
- `local-ai agent` - Run agents standalone without the full LocalAI server
- `local-ai mcp-server` - Run the LocalAI admin tool surface as a stdio MCP server (controls a remote LocalAI instance over HTTP)
-- `local-ai worker` - Start a worker for distributed mode (generic, backend-agnostic)
+- `local-ai worker` - Start a worker for distributed mode (generic, backend-agnostic; needs only an outbound route to the frontend, no message bus)
- `local-ai p2p-worker` - Run workers to distribute workload via p2p (llama.cpp-only)
-- `local-ai agent-worker` - Start an agent worker for distributed mode (executes agent chats via NATS)
+- `local-ai agent-worker` - Start an agent worker for distributed mode (executes agent chats via NATS, which this command still needs)
- `local-ai util` - Utility commands
- `local-ai explorer` - Run P2P explorer
- `local-ai federated` - Run LocalAI in federated mode
diff --git a/go.mod b/go.mod
index 5ed7e0b515d7..e1b1a72aabf1 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,7 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0
github.com/labstack/echo/v4 v4.15.1
github.com/libp2p/go-libp2p v0.48.0
+ github.com/libp2p/go-yamux/v5 v5.1.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
@@ -53,6 +54,7 @@ require (
github.com/otiai10/openaigo v1.7.0
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
github.com/prometheus/client_golang v1.23.2
+ github.com/quasilyte/go-ruleguard/dsl v0.3.23
github.com/robfig/cron/v3 v3.0.1
github.com/russross/blackfriday v1.6.0
github.com/sashabaranov/go-openai v1.41.2
@@ -321,7 +323,6 @@ require (
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
- github.com/libp2p/go-yamux/v5 v5.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index 6a0be2c19cc8..a510df3dfd3b 100644
--- a/go.sum
+++ b/go.sum
@@ -1206,6 +1206,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
+github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
diff --git a/hack/lint/backend_wrappers.go b/hack/lint/backend_wrappers.go
new file mode 100644
index 000000000000..c320ac9e5ca0
--- /dev/null
+++ b/hack/lint/backend_wrappers.go
@@ -0,0 +1,46 @@
+//go:build ruleguard
+
+// Package gorules holds the go-ruleguard rules gocritic runs inside
+// `make lint`. It is never compiled into the binary: the build tag keeps it out
+// of every normal build, and golangci-lint loads the file as data.
+package gorules
+
+import "github.com/quasilyte/go-ruleguard/dsl"
+
+// backendWrapperMustBeUnwrappable fires on a struct that decorates a gRPC
+// backend by embedding the raw interface.
+//
+// This exists because the same defect shipped twice. A wrapper that embeds
+// grpc.Backend inherits exactly the methods Backend declares and nothing else.
+// grpc.DialErrorReporter is deliberately NOT on Backend, so a wrapped client
+// silently stops answering "did the transport fail, or did the backend die",
+// and every guard built on that answer reads nil. The consequence is not
+// subtle: core/services/nodes and pkg/model delete replica rows and stop
+// backends on that answer, so a wrapper that swallows it turns a momentary loss
+// of route into fleet-wide model reclamation.
+//
+// The rule is SYNTACTIC, and deliberately so. The obvious formulation, "embeds
+// a backend and has no Unwrap", cannot be written: HasMethod rejects inline
+// signatures outright ("inline func signatures are not supported yet"), its
+// method-reference form needs a package ruleguard's own typechecker can import
+// and it cannot import this module, and Implements tests the VALUE method set
+// while every Unwrap here would be on a pointer receiver. So instead of
+// checking for the method, this checks for the shape that CANNOT lack it:
+// grpc.WrappedBackend provides the same pass-through method set plus Unwrap,
+// with a value receiver, so anything embedding it is transparent by
+// construction. Forgetting is then not expressible rather than merely
+// discouraged, which is the same move loopbackService makes in the worker.
+//
+// Test doubles are excluded by path in .golangci.yml: they embed a NIL backend
+// to inherit the interface's method set, decorate nothing, and have no
+// transport answer to forward.
+func backendWrapperMustBeUnwrappable(m dsl.Matcher) {
+ m.Import("github.com/mudler/LocalAI/pkg/grpc")
+
+ m.Match(
+ `type $w struct { $*_; grpc.Backend; $*_ }`,
+ `type $w struct { $*_; grpc.ControlBackend; $*_ }`,
+ `type $w struct { $*_; grpc.InferenceBackend; $*_ }`,
+ ).
+ Report(`$w decorates a gRPC backend by embedding the raw interface, so grpc.LastDialErrorOf cannot see through it and every transport-failure guard behind it reads nil, which deletes replica rows for workers that are merely unroutable. Embed grpc.WrappedBackend instead: it gives the same pass-through plus Unwrap. If $w decorates nothing, silence this with //nolint:gocritic and say so.`)
+}
diff --git a/pkg/grpc/backend.go b/pkg/grpc/backend.go
index 93dde00991b7..42b46e39b98d 100644
--- a/pkg/grpc/backend.go
+++ b/pkg/grpc/backend.go
@@ -2,6 +2,7 @@ package grpc
import (
"context"
+ "net"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"google.golang.org/grpc"
@@ -29,7 +30,141 @@ func NewClientWithToken(address string, parallel bool, wd WatchDog, enableWatchD
return buildClient(address, parallel, wd, enableWatchDog, token)
}
-func buildClient(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string) Backend {
+// NewClientWithDialer creates a gRPC client that reaches its backend through
+// dialer rather than by connecting to address.
+//
+// It is what distributed mode uses to reach a backend process on a worker: the
+// worker holds one multiplexed tunnel to a frontend replica and listens on
+// nothing, so address names which backend process the stream is for and the
+// dialer decides how the stream gets there. A nil dialer is a programming
+// error on this path rather than a fallback, because falling back to a direct
+// dial would work in a single-replica test and fail in production; callers with
+// no dialer call NewClientWithToken and mean it.
+func NewClientWithDialer(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string, dialer func(ctx context.Context, addr string) (net.Conn, error)) Backend {
+ if bc, ok := embeds[address]; ok {
+ return bc
+ }
+ // Assigned on the concrete type rather than through a checked assertion:
+ // an assertion that failed would silently hand back a client that dials
+ // the address directly, which is the exact bypass this constructor exists
+ // to close.
+ c := buildClient(address, parallel, wd, enableWatchDog, token)
+ // Wrapped rather than stored bare, so every dial outcome is recorded. This
+ // is the seam that carries the reason a dial failed past gRPC, which
+ // flattens it into codes.Unavailable; see (*Client).LastDialError.
+ c.dialer = func(ctx context.Context, addr string) (net.Conn, error) {
+ conn, err := dialer(ctx, addr)
+ c.recordDialErr(err)
+ return conn, err
+ }
+ return c
+}
+
+// DialErrorReporter is implemented by a Backend that reaches its process
+// through a custom transport and can say whether that transport, rather than
+// the process, is what failed.
+//
+// It is a separate interface and NOT part of Backend on purpose: only the
+// handful of callers that act on the difference need it, and widening Backend
+// would make every wrapper and every test double implement a method they have
+// no answer for.
+type DialErrorReporter interface {
+ LastDialError() error
+}
+
+// BackendUnwrapper is implemented by a Backend that DECORATES another one.
+//
+// Every wrapper in this codebase must implement it, and the reason is a defect
+// that shipped: a wrapper embeds the Backend interface, so it inherits every
+// declared method and NOTHING else. DialErrorReporter is deliberately not
+// declared on Backend, so a wrapped client silently stopped answering "did the
+// transport fail" and the guard built on that answer read nil in production
+// while passing every spec that constructed a raw client by hand.
+//
+// Implementing this is what makes a decorator transparent to LastDialErrorOf,
+// and it is one line rather than a re-implementation per wrapper, so there is
+// no per-wrapper policy to get wrong.
+type BackendUnwrapper interface {
+ Unwrap() Backend
+}
+
+// WrappedBackend is what a decorator embeds INSTEAD of a Backend.
+//
+// It provides the pass-through method set exactly as embedding the interface
+// did, and it provides Unwrap, so a decorator built on it is transparent to
+// LastDialErrorOf by CONSTRUCTION rather than by remembering. That is the whole
+// design: the same defect shipped twice, both times because a wrapper inherited
+// only what Backend declares and DialErrorReporter is deliberately not on
+// Backend, so the transport answer every reaping guard depends on silently
+// became nil.
+//
+// Forgetting is therefore no longer possible for anything that embeds this, and
+// embedding the raw interface instead is caught by the ruleguard rule in
+// hack/lint/. A compile-time assertion cannot do that job: it only fires for a
+// type that already declares the intent, which is precisely the type that did
+// not forget.
+//
+// Unwrap takes a VALUE receiver, which is safe because this holds one interface
+// and no lock, and is what lets the value type of any embedder satisfy
+// BackendUnwrapper.
+//
+// It is NOT for every decorator. Embedding this promotes the whole Backend
+// surface as pass-through, so a decorator that deliberately embeds a NARROWER
+// interface to force itself to handle each method (see
+// nodes.InFlightTrackingClient) must keep doing that and declare Unwrap by
+// hand; adopting this there would restore pass-through silently.
+type WrappedBackend struct{ Backend }
+
+// Unwrap exposes the decorated client.
+func (w WrappedBackend) Unwrap() Backend { return w.Backend }
+
+// maxBackendUnwrapDepth bounds the walk below. Three wrappers exist today and
+// they nest at most two deep; the bound is a guard against a cycle a future
+// wrapper could introduce, not a limit anything real approaches.
+const maxBackendUnwrapDepth = 16
+
+// LastDialErrorOf reports why the most recent dial under b failed, looking
+// THROUGH any decorators, or nil when the dial succeeded or nothing under b has
+// a custom transport.
+//
+// It is the single implementation of that question. Its callers
+// (core/services/nodes and pkg/model) each had their own type assertion, and an
+// assertion cannot see past a wrapper: in production the client handed to
+// pkg/model is an *InFlightTrackingClient over a *FileStagingClient over the
+// real one, so both callers were asking a wrapper that had no answer and
+// reading nil as "the transport was fine".
+//
+// WHAT IT ANSWERS IS NOT "was this the transport's fault". It answers "what did
+// the dialler last return", and in distributed mode some of those values are a
+// WORKER'S OWN REFUSAL, which means the tunnel worked and the worker spoke.
+// Telling those apart is cluster.IsWorkerAnswer, and the two production callers
+// (nodes.unroutable, model.transportFailure) both go through it. A new caller
+// that matches on sentinels of its own would be re-creating the collapse this
+// phase spent two rounds removing: the reap guards and the dialler would stop
+// agreeing on which errors are evidence.
+//
+// Nothing structural prevents that, unlike the WrappedBackend rule in
+// hack/lint/ which makes decorator transparency impossible to forget. With two
+// callers, both funnelling through one predicate, a ruleguard rule is not worth
+// its false positives; if a third appears, it is. Recorded as a phase-3 note.
+func LastDialErrorOf(b Backend) error {
+ for range maxBackendUnwrapDepth {
+ if b == nil {
+ return nil
+ }
+ if reporter, ok := b.(DialErrorReporter); ok {
+ return reporter.LastDialError()
+ }
+ wrapper, ok := b.(BackendUnwrapper)
+ if !ok {
+ return nil
+ }
+ b = wrapper.Unwrap()
+ }
+ return nil
+}
+
+func buildClient(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string) *Client {
if !enableWatchDog {
wd = nil
}
diff --git a/pkg/grpc/client.go b/pkg/grpc/client.go
index a6f8947eba61..fe4a5d182131 100644
--- a/pkg/grpc/client.go
+++ b/pkg/grpc/client.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"sync"
"time"
@@ -32,6 +33,21 @@ type Client struct {
inFlight int
parallel bool
token string
+ // dialer replaces the transport gRPC would otherwise use to reach address.
+ // In distributed mode it is a stream on the worker's tunnel, so address
+ // stops being a socket to connect to and becomes the name of a backend
+ // process inside the worker; see core/services/cluster.WorkerDialer. nil
+ // keeps gRPC's own TCP dial, which is what every non-distributed caller
+ // wants.
+ dialer func(ctx context.Context, addr string) (net.Conn, error)
+
+ // dialErrMu guards lastDialErr. Its own mutex rather than the embedded one:
+ // the embedded Mutex guards inFlight and is taken on every call, and a
+ // dialer runs underneath gRPC's own machinery where reentering it is not
+ // something this type can reason about.
+ dialErrMu sync.Mutex
+ lastDialErr error
+
sync.Mutex
opMutex sync.Mutex
wd WatchDog
@@ -80,6 +96,12 @@ func (c *Client) dial() (*grpc.ClientConn, error) {
if c.token != "" {
opts = append(opts, grpc.WithPerRPCCredentials(bearerToken{token: c.token}))
}
+ if c.dialer != nil {
+ // The address is still passed to grpc.NewClient because it is what
+ // names the target in every error message and in the authority header;
+ // what it no longer decides is where the bytes go.
+ opts = append(opts, grpc.WithContextDialer(c.dialer))
+ }
return grpc.NewClient(c.address, opts...)
}
@@ -1408,3 +1430,51 @@ func (c *Client) ModelMetadata(ctx context.Context, in *pb.ModelOptions, opts ..
client := pb.NewBackendClient(conn)
return client.ModelMetadata(ctx, in, opts...)
}
+
+// LastDialError returns the error from the most recent attempt by this client's
+// custom dialer, or nil when the last attempt succeeded or there is no custom
+// dialer.
+//
+// It exists because gRPC destroys the distinction its callers need. A dialer
+// failure reaches an RPC as codes.Unavailable with the cause flattened into a
+// message string, and codes.Unavailable is ALSO what a backend process that
+// died produces. Those two call for opposite actions: a dead backend's registry
+// row should be reaped, and a transport that could not reach a live backend
+// must never cause one to be. Recording the error here is what lets a caller
+// tell them apart, with the original error VALUE intact, so
+// core/services/cluster's sentinels survive the trip.
+//
+// Scope, stated exactly, including where it is NOT exact.
+//
+// This is the last dial on this CLIENT, not the last dial for a particular RPC.
+// Three of the four callers build a client for one probe and close it, so
+// attribution there is exact. The fourth, pkg/model's checkIsLoaded, reads the
+// model's long-lived SHARED client and consults this after HealthCheck has
+// released opMutex, so a concurrent RPC on the same client can record or clear
+// the value inside that window. An earlier version of this comment claimed
+// exactness for all four; it was wrong.
+//
+// The imprecision is accepted there rather than designed away, and the reason
+// is which way it can go. A caller consults this only when its own RPC already
+// failed, so the two outcomes are: a concurrent dial FAILURE makes a genuinely
+// dead backend look unreachable-for-now, and its row survives one extra round
+// until the transport recovers; or a concurrent dial SUCCESS clears the value
+// and a transport failure reads as a backend failure, which is exactly the
+// behaviour that existed before any of this. Neither is a new hazard, and the
+// second requires a transport that recovered inside the window. Making it exact
+// would mean threading a per-call handle through every Backend method, which is
+// a far larger change than the failure it would prevent.
+func (c *Client) LastDialError() error {
+ c.dialErrMu.Lock()
+ defer c.dialErrMu.Unlock()
+ return c.lastDialErr
+}
+
+// recordDialErr stores the outcome of one dial. A success CLEARS the previous
+// failure rather than leaving it, so a client that recovered does not keep
+// reporting a dial error that no longer describes anything.
+func (c *Client) recordDialErr(err error) {
+ c.dialErrMu.Lock()
+ c.lastDialErr = err
+ c.dialErrMu.Unlock()
+}
diff --git a/pkg/mcp/localaitools/dto.go b/pkg/mcp/localaitools/dto.go
index 1055f86d918d..dd9113df9732 100644
--- a/pkg/mcp/localaitools/dto.go
+++ b/pkg/mcp/localaitools/dto.go
@@ -97,13 +97,17 @@ type SystemInfo struct {
}
// Node is one entry in list_nodes.
+//
+// It carries no address. A worker holds one outbound tunnel to a frontend
+// replica and advertises no endpoint, so both `address` and `http_address` are
+// empty on every node registered by a current worker. They were dropped rather
+// than left empty because this struct is read by the LocalAI Assistant, and an
+// always-blank field an operator can ask about invites an answer built on it.
type Node struct {
- ID string `json:"id"`
- Address string `json:"address,omitempty"`
- HTTPAddress string `json:"http_address,omitempty"`
- TotalVRAM uint64 `json:"total_vram,omitempty"`
- Healthy bool `json:"healthy"`
- LastSeen string `json:"last_seen,omitempty"`
+ ID string `json:"id"`
+ TotalVRAM uint64 `json:"total_vram,omitempty"`
+ Healthy bool `json:"healthy"`
+ LastSeen string `json:"last_seen,omitempty"`
}
// SetNodeVRAMBudgetRequest is the input for set_node_vram_budget. It PUTs
diff --git a/pkg/mcp/localaitools/dto_test.go b/pkg/mcp/localaitools/dto_test.go
index 865d00e8c3de..2d807d0b7ccc 100644
--- a/pkg/mcp/localaitools/dto_test.go
+++ b/pkg/mcp/localaitools/dto_test.go
@@ -31,7 +31,7 @@ var _ = Describe("DTOs round-trip through JSON", func() {
roundTripDTO(InstallBackendRequest{GalleryName: "g", BackendName: "b"})
roundTripDTO(Backend{Name: "n", Installed: true})
roundTripDTO(SystemInfo{Version: "v1", Distributed: false, ModelsPath: "/tmp", LoadedModels: []string{"a"}, InstalledBackends: []string{"x"}})
- roundTripDTO(Node{ID: "n", Address: "a", HTTPAddress: "h", TotalVRAM: 100, Healthy: true, LastSeen: "now"})
+ roundTripDTO(Node{ID: "n", TotalVRAM: 100, Healthy: true, LastSeen: "now"})
roundTripDTO(VRAMEstimateRequest{ModelName: "m", ContextSize: 4096, GPULayers: -1, KVQuantBits: 8})
roundTripDTO(ImportModelURIRequest{URI: "u", BackendPreference: "llama-cpp", Overrides: map[string]any{"k": "v"}})
roundTripDTO(ImportModelURIResponse{JobID: "j", DiscoveredModelName: "m", AmbiguousBackend: true, Modality: "tts", BackendCandidates: []string{"a", "b"}, Hint: "h"})
diff --git a/pkg/mcp/localaitools/httpapi/client.go b/pkg/mcp/localaitools/httpapi/client.go
index 923f35eba86c..791ddc1db859 100644
--- a/pkg/mcp/localaitools/httpapi/client.go
+++ b/pkg/mcp/localaitools/httpapi/client.go
@@ -458,11 +458,11 @@ func (c *Client) SystemInfo(ctx context.Context) (*localaitools.SystemInfo, erro
}
func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
+ // address / http_address are deliberately not decoded: a worker advertises
+ // no endpoint, so both are empty on every current node.
var raw []struct {
- ID string `json:"id"`
- Address string `json:"address"`
- HTTPAddress string `json:"http_address"`
- Status string `json:"status"`
+ ID string `json:"id"`
+ Status string `json:"status"`
}
if err := c.do(ctx, http.MethodGet, routeNodes, nil, &raw); err != nil {
// Treat 404/disabled as "no nodes" to keep parity with single-process.
@@ -474,10 +474,8 @@ func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
out := make([]localaitools.Node, 0, len(raw))
for _, n := range raw {
out = append(out, localaitools.Node{
- ID: n.ID,
- Address: n.Address,
- HTTPAddress: n.HTTPAddress,
- Healthy: n.Status == "healthy",
+ ID: n.ID,
+ Healthy: n.Status == "healthy",
})
}
return out, nil
diff --git a/pkg/model/backend_log_store.go b/pkg/model/backend_log_store.go
index c5b5253ddc40..3c60f34a3736 100644
--- a/pkg/model/backend_log_store.go
+++ b/pkg/model/backend_log_store.go
@@ -344,3 +344,45 @@ func (s *BackendLogStore) Subscribe(modelID string) (chan BackendLogLine, func()
return ch, unsubscribe
}
+
+// SubscriberCount reports how many live subscriptions exist for modelID,
+// resolving the ID with the same exact-key / replica-prefix rules as Subscribe.
+//
+// Streaming handlers send a GetLines snapshot before they call Subscribe, so a
+// line appended between those two calls reaches the buffer but no channel. A
+// caller that has to observe a line it appends itself must therefore wait for
+// the subscription to exist rather than assume the handler got there first.
+func (s *BackendLogStore) SubscriberCount(modelID string) int {
+ s.mu.RLock()
+ exact, exactOK := s.buffers[modelID]
+ var replicas []*backendLogBuffer
+ if !strings.Contains(modelID, replicaSeparator) {
+ prefix := modelID + replicaSeparator
+ for k, b := range s.buffers {
+ if strings.HasPrefix(k, prefix) {
+ replicas = append(replicas, b)
+ }
+ }
+ }
+ s.mu.RUnlock()
+
+ // Lock order in this type is always s.mu before any buffer lock — Subscribe
+ // holds s.mu.RLock across its replica registrations, which take buf.mu — so
+ // counting after releasing s.mu keeps that order rather than inverting it.
+ // The total is therefore a sample, not a snapshot: a concurrent Subscribe
+ // can register a further buffer while this loop runs.
+ count := func(buf *backendLogBuffer) int {
+ buf.mu.Lock()
+ defer buf.mu.Unlock()
+ return len(buf.subscribers)
+ }
+
+ total := 0
+ if exactOK {
+ total += count(exact)
+ }
+ for _, b := range replicas {
+ total += count(b)
+ }
+ return total
+}
diff --git a/pkg/model/backend_log_store_test.go b/pkg/model/backend_log_store_test.go
index 775e07cdb561..593bcf5a23fa 100644
--- a/pkg/model/backend_log_store_test.go
+++ b/pkg/model/backend_log_store_test.go
@@ -76,6 +76,38 @@ var _ = Describe("BackendLogStore", func() {
})
})
+ Describe("SubscriberCount", func() {
+ It("reports zero before anyone subscribes and drops back after unsubscribe", func() {
+ s.AppendLine("model-a", "stderr", "preload")
+ Expect(s.SubscriberCount("model-a")).To(Equal(0))
+
+ _, unsubscribe := s.Subscribe("model-a")
+ Expect(s.SubscriberCount("model-a")).To(Equal(1))
+
+ unsubscribe()
+ Expect(s.SubscriberCount("model-a")).To(Equal(0))
+ })
+
+ // Subscribe resolves a bare model ID across every replica buffer, so the
+ // count has to follow the same rule or a caller waiting on it would give
+ // up while a perfectly good subscription was in place.
+ It("sums the replica buffers a bare model ID resolves to", func() {
+ s.AppendLine("model-a#0", "stderr", "preload-r0")
+ s.AppendLine("model-a#1", "stderr", "preload-r1")
+
+ _, unsubscribe := s.Subscribe("model-a")
+ defer unsubscribe()
+
+ Expect(s.SubscriberCount("model-a")).To(Equal(2))
+ Expect(s.SubscriberCount("model-a#0")).To(Equal(1))
+ Expect(s.SubscriberCount("model-b")).To(Equal(0))
+ })
+
+ It("returns zero for a model that has no buffer at all", func() {
+ Expect(s.SubscriberCount("never-seen")).To(Equal(0))
+ })
+ })
+
Describe("Subscribe", func() {
// Confirms the WebSocket streaming path (the live tail UI) receives
// lines from every replica when the caller subscribes by bare modelID.
diff --git a/pkg/model/connection_evicting_client.go b/pkg/model/connection_evicting_client.go
index 00d42d200f96..c81e006b4ecf 100644
--- a/pkg/model/connection_evicting_client.go
+++ b/pkg/model/connection_evicting_client.go
@@ -16,28 +16,51 @@ import (
// still returned to the caller — the NEXT request will trigger rescheduling
// via SmartRouter.
type ConnectionEvictingClient struct {
- grpc.Backend
+ grpc.WrappedBackend
modelID string
evict func()
once sync.Once
}
+var _ grpc.BackendUnwrapper = (*ConnectionEvictingClient)(nil)
+
func newConnectionEvictingClient(inner grpc.Backend, modelID string, evict func()) grpc.Backend {
return &ConnectionEvictingClient{
- Backend: inner,
- modelID: modelID,
- evict: evict,
+ WrappedBackend: grpc.WrappedBackend{Backend: inner},
+ modelID: modelID,
+ evict: evict,
}
}
func (c *ConnectionEvictingClient) checkErr(err error) {
- if err != nil && isConnectionError(err) {
- c.once.Do(func() {
- xlog.Warn("Connection error during inference, evicting model from cache",
- "model", c.modelID, "error", err)
- c.evict()
- })
+ if err == nil || !isConnectionError(err) {
+ return
+ }
+ // The fifth site of the same shape, and the one reached during INFERENCE
+ // rather than a health check. evict() runs ShutdownModel, which for a remote
+ // model sends backend.stop over NATS to every node holding it and deletes
+ // every replica row. In distributed mode the client underneath reaches the
+ // backend over the worker's tunnel, and a failure of THAT transport arrives
+ // as the same codes.Unavailable a dead backend produces; evicting on it
+ // stops a model that is loaded and serving, on a worker that is
+ // heartbeating. A locally spawned backend has no custom transport, so this
+ // reports nil and the behaviour there is exactly what it always was.
+ // transportFailure and not LastDialErrorOf: a refusal the WORKER wrote is
+ // the worker answering that it could not reach the process, which is what a
+ // crashed backend produces now that a worker listens on nothing. Treating
+ // that as a transport failure kept a genuinely dead model loaded and
+ // failing every request, which is the mirror image of the mistake this
+ // guard exists to prevent.
+ if dialErr := transportFailure(c.Backend); dialErr != nil {
+ xlog.Warn("Inference failed because the worker could not be reached; keeping the model",
+ "model", c.modelID, "error", dialErr)
+ return
}
+ c.once.Do(func() {
+ xlog.Warn("Connection error during inference, evicting model from cache",
+ "model", c.modelID, "error", err)
+ c.evict()
+ })
}
// --- Intercepted inference methods ---
diff --git a/pkg/model/loader.go b/pkg/model/loader.go
index 322b11e36c96..5b8bbfc18963 100644
--- a/pkg/model/loader.go
+++ b/pkg/model/loader.go
@@ -12,6 +12,8 @@ import (
"sync/atomic"
"time"
+ "github.com/mudler/LocalAI/core/services/cluster"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/utils"
@@ -685,6 +687,26 @@ func (ml *ModelLoader) checkIsLoaded(s string) *Model {
// Remote/distributed model — no local process to check.
// Only evict on definitive connection errors (node is down).
// Timeouts may mean the node is busy, so keep the model cached.
+ //
+ // "The node is down" is exactly what this can no longer conclude on
+ // its own. In distributed mode the client reaches the backend over
+ // the worker's tunnel, and a failure of THAT transport (the replica
+ // holding the tunnel is restarting, the worker has not dialled in
+ // yet after a frontend-first upgrade) arrives as the same
+ // codes.Unavailable a dead worker produces. Evicting on it would
+ // unload a model that is loaded and serving. The client records
+ // which of the two happened; see grpc.DialErrorReporter.
+ // The client here is long-lived and shared, so this reads the last
+ // dial on it rather than the one this HealthCheck made; see
+ // (*grpc.Client).LastDialError for why that imprecision is
+ // accepted. Both directions of it land on behaviour that already
+ // existed, and the common case (a worker with no route at all) has
+ // no concurrent success to clear the value.
+ if dialErr := transportFailure(client); dialErr != nil {
+ xlog.Warn("Remote model health check could not reach the worker, keeping cached",
+ "model", s, "error", dialErr)
+ return m
+ }
if isConnectionError(err) {
xlog.Warn("Remote model unreachable (connection error), removing from cache", "model", s, "error", err)
if delErr := ml.deleteProcess(cTimeout, s, false); delErr != nil {
@@ -709,3 +731,36 @@ func (ml *ModelLoader) checkIsLoaded(s string) *Model {
m.MarkHealthy()
return m
}
+
+// transportFailure reports why a call never reached the backend, or nil when it
+// did reach one.
+//
+// It is the one question that separates "this backend is gone" from "this
+// process cannot currently get to it", and gRPC does not answer it: a dialer
+// failure and a dead listener both surface as codes.Unavailable. A client with
+// no custom transport answers nil, which is right for every locally spawned
+// backend, where the address IS a socket on this machine and a failed
+// connection really does mean the process died.
+func transportFailure(client grpc.Backend) error {
+ // LastDialErrorOf and not a type assertion. The client reaching this
+ // function for a routed remote model is an *InFlightTrackingClient, often
+ // over a *FileStagingClient, and an assertion on the outermost type reads
+ // nil for both: they embed grpc.Backend, which does not declare
+ // LastDialError. That is exactly how this guard shipped inert.
+ dialErr := grpc.LastDialErrorOf(client)
+ if dialErr == nil {
+ return nil
+ }
+ // A refusal WRITTEN BY THE WORKER is not a transport failure, however much
+ // it looks like one from here: the tunnel carried the request, the worker
+ // read it and answered that it could not reach the process the stream
+ // named. That is the ordinary shape of a crashed backend now that a worker
+ // listens on nothing, and reporting it as "could not reach the worker"
+ // pinned the model in this cache forever. cluster.Dial keeps these three
+ // out of its no-route umbrella for exactly this question; see
+ // cluster.IsWorkerAnswer.
+ if cluster.IsWorkerAnswer(dialErr) {
+ return nil
+ }
+ return dialErr
+}
diff --git a/pkg/model/remote_unroutable_internal_test.go b/pkg/model/remote_unroutable_internal_test.go
new file mode 100644
index 000000000000..eb060458b261
--- /dev/null
+++ b/pkg/model/remote_unroutable_internal_test.go
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: MIT
+
+package model
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/core/services/cluster"
+ grpc "github.com/mudler/LocalAI/pkg/grpc"
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ "github.com/mudler/LocalAI/pkg/system"
+)
+
+// refusalFromWorker builds the error the frontend's dialler really returns when
+// a worker refuses one of its streams, by writing the refusal with the worker's
+// own writer and reading it back with the frontend's own reader.
+//
+// It matters that this goes over the wire rather than taking the sentinel
+// directly. A worker that answers is CONNECTED, so its refusal is not a
+// transport failure however much it looks like one from here, and a spec
+// asserting against a hand-made value would not notice if the wire stopped
+// carrying the distinction.
+func refusalFromWorker(reason error) error {
+ GinkgoHelper()
+ var frame bytes.Buffer
+ Expect(cluster.WriteStreamRefusal(&frame, reason)).To(Succeed())
+ readBack := cluster.ReadStreamReply(&frame)
+ Expect(readBack).To(MatchError(reason))
+ return fmt.Errorf("opening %q on node %q: %w", "grpc", "node-1", readBack)
+}
+
+var _ = Describe("the health check on a remote model whose transport failed", func() {
+ // The fourth site of the same shape as the reconciler, the health monitor
+ // and the router, found by sweeping rather than by being named.
+ //
+ // checkIsLoaded evicts a remote model on a "connection error", which used
+ // to mean exactly one thing: the worker's socket did not answer. In
+ // distributed mode the client reaches the backend over the worker's tunnel,
+ // and a failure of THAT transport arrives as the same codes.Unavailable.
+ // Evicting on it unloads a model that is loaded and serving, on a worker
+ // that is heartbeating.
+ var ml *ModelLoader
+
+ BeforeEach(func() {
+ systemState, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
+ Expect(err).ToNot(HaveOccurred())
+ ml = NewModelLoader(systemState)
+ })
+
+ It("keeps the model when the tunnel dial failed", func() {
+ client := grpc.NewClientWithDialer("10.0.0.1:9001", false, nil, false, "",
+ func(context.Context, string) (net.Conn, error) {
+ return nil, errors.New("cluster: no route from this replica to that worker")
+ })
+ m := NewModelWithClient("remote-model", "10.0.0.1:9001", client)
+ ml.store.Set("remote-model", m)
+
+ Expect(ml.checkIsLoaded("remote-model")).To(BeIdenticalTo(m),
+ "a model on a worker this frontend cannot route to must stay cached, not be unloaded")
+ _, stillThere := ml.store.Get("remote-model")
+ Expect(stillThere).To(BeTrue())
+ })
+
+ It("evicts a remote model whose backend the WORKER ITSELF could not reach", func() {
+ // The shape a crashed backend takes since workers stopped listening:
+ // the tunnel carried the request, the worker read it and answered that
+ // nothing is listening on that port. That is the worker speaking about
+ // its backend, not a transport failure, and reading it as one pinned a
+ // genuinely dead model in this cache forever, failing every request.
+ client := grpc.NewClientWithDialer("10.0.0.1:9001", false, nil, false, "",
+ func(context.Context, string) (net.Conn, error) {
+ return nil, refusalFromWorker(cluster.ErrStreamTargetUnavailable)
+ })
+ m := NewModelWithClient("refused-model", "10.0.0.1:9001", client)
+ ml.store.Set("refused-model", m)
+
+ Expect(ml.checkIsLoaded("refused-model")).To(BeNil())
+ _, stillThere := ml.store.Get("refused-model")
+ Expect(stillThere).To(BeFalse())
+ })
+
+ It("still evicts a remote model whose worker WAS reached and did not answer", func() {
+ // The other direction, so the new check cannot pass by never evicting.
+ // No custom dialer, so the transport reports nothing and a connection
+ // error means what it always meant.
+ client := grpc.NewClientWithToken("127.0.0.1:1", false, nil, false, "")
+ m := NewModelWithClient("dead-model", "127.0.0.1:1", client)
+ ml.store.Set("dead-model", m)
+
+ Expect(ml.checkIsLoaded("dead-model")).To(BeNil())
+ _, stillThere := ml.store.Get("dead-model")
+ Expect(stillThere).To(BeFalse())
+ })
+})
+
+var _ = Describe("the eviction wrapper on a remote model whose transport failed", func() {
+ // The FIFTH site of the same shape, found by sweeping the decorators rather
+ // than being named. initializers.go builds this wrapper for exactly the
+ // remote models the router produces, and its evict callback runs
+ // ShutdownModel, which sends backend.stop over NATS to every node holding
+ // the model and deletes every replica row. It fires during INFERENCE, not
+ // on a health check, so a tunnel blip mid-request was enough.
+ failingDial := func(cause error) grpc.Backend {
+ return grpc.NewClientWithDialer("10.0.0.1:9001", false, nil, false, "",
+ func(context.Context, string) (net.Conn, error) { return nil, cause })
+ }
+
+ It("does not evict when the worker could not be reached", func() {
+ evicted := 0
+ client := newConnectionEvictingClient(
+ failingDial(errors.New("cluster: no route from this replica to that worker")),
+ "remote-model", func() { evicted++ })
+
+ _, err := client.Predict(context.Background(), &pb.PredictOptions{})
+ Expect(err).To(HaveOccurred())
+ Expect(evicted).To(BeZero(),
+ "a worker this frontend cannot route to must not have its backend stopped and its rows deleted")
+ })
+
+ It("evicts when the WORKER ITSELF refused the stream to the backend", func() {
+ // The same rule on the INFERENCE path. A refusal the worker wrote is
+ // the worker reporting its backend gone, so the model must be evicted
+ // here exactly as a locally spawned one would be; the guard is for a
+ // route this frontend lost, which is a different condition.
+ evicted := 0
+ client := newConnectionEvictingClient(
+ failingDial(refusalFromWorker(cluster.ErrStreamTargetUnavailable)),
+ "refused-model", func() { evicted++ })
+
+ _, err := client.Predict(context.Background(), &pb.PredictOptions{})
+ Expect(err).To(HaveOccurred())
+ Expect(evicted).To(Equal(1))
+ })
+
+ It("does not evict on a refusal code this frontend does not recognise", func() {
+ // The boundary. An unrecognised code is a newer worker's vocabulary,
+ // which WorkerDialer reports under the no-route umbrella, and acting on
+ // it would let a worker upgrade stop models that are running.
+ evicted := 0
+ client := newConnectionEvictingClient(
+ failingDial(fmt.Errorf("reaching node %q: %w: opening %q: tunnel stream refused with unrecognised code %q: %s",
+ "node-1", cluster.ErrNoRoute, "grpc", "quiesced", "this worker is draining")),
+ "remote-model", func() { evicted++ })
+
+ _, err := client.Predict(context.Background(), &pb.PredictOptions{})
+ Expect(err).To(HaveOccurred())
+ Expect(evicted).To(BeZero())
+ })
+
+ It("still evicts when the worker WAS reached and the connection failed", func() {
+ // The other direction. No custom dialer, so nothing reports a transport
+ // failure and a connection error means what it always meant.
+ evicted := 0
+ client := newConnectionEvictingClient(
+ grpc.NewClientWithToken("127.0.0.1:1", false, nil, false, ""),
+ "dead-model", func() { evicted++ })
+
+ _, err := client.Predict(context.Background(), &pb.PredictOptions{})
+ Expect(err).To(HaveOccurred())
+ Expect(evicted).To(Equal(1))
+ })
+
+ It("is transparent to the transport question, so a wrapper of it still sees through", func() {
+ client := newConnectionEvictingClient(
+ failingDial(errors.New("cluster: no route from this replica to that worker")),
+ "remote-model", func() {})
+ _, _ = client.Predict(context.Background(), &pb.PredictOptions{})
+ Expect(grpc.LastDialErrorOf(client)).ToNot(BeNil())
+ })
+})
diff --git a/pkg/natsauth/mint_test.go b/pkg/natsauth/mint_test.go
index 4d5bb77cee62..7c849cf16203 100644
--- a/pkg/natsauth/mint_test.go
+++ b/pkg/natsauth/mint_test.go
@@ -36,8 +36,20 @@ var _ = Describe("MintWorkerJWT", func() {
uc, err := jwt.DecodeUserClaims(token)
Expect(err).NotTo(HaveOccurred())
- Expect(uc.Permissions.Sub.Allow).To(ContainElement("nodes.550e8400-e29b-41d4-a716-446655440000.>"))
- Expect(uc.Permissions.Pub.Allow).To(ContainElement("nodes.550e8400-e29b-41d4-a716-446655440000.backend.install.*.progress"))
+ // A backend worker opens no bus connection at all, so its node subtree
+ // went with it. The JWT is still minted at registration and simply
+ // unused; asserting BOTH lists are exactly the inbox is what keeps it
+ // from silently becoming an unrestricted credential, since NATS reads
+ // an empty allow list as no restriction.
+ Expect(uc.Permissions.Sub.Allow).To(ConsistOf("_INBOX.>"))
+ // The install-progress subject is gone with the carrier: progress is a
+ // line in the install response now, so a minted worker JWT must not
+ // still be granted a publish right for it. File staging went the same
+ // way, so a minted worker JWT publishes nowhere but its own inbox.
+ Expect(uc.Permissions.Pub.Allow).To(ConsistOf("_INBOX.>"))
+ for _, subj := range uc.Permissions.Pub.Allow {
+ Expect(subj).NotTo(ContainSubstring("backend.install"))
+ }
})
It("mints agent permissions without backend install subscribe", func() {
diff --git a/pkg/natsauth/permissions.go b/pkg/natsauth/permissions.go
index d44e51990480..4c081c672841 100644
--- a/pkg/natsauth/permissions.go
+++ b/pkg/natsauth/permissions.go
@@ -9,6 +9,18 @@ func workerSubjectToken(nodeID string) string {
}
// WorkerPermissions returns NATS pub/sub allow lists for a registered node.
+//
+// It serves AGENT nodes. They are the only workers left that connect to the
+// bus: an agent worker subscribes to the queue subjects listed below, while a
+// backend worker connects to no bus at all, because every verb a frontend gives
+// it is an HTTP route on its own server reached through its outbound tunnel
+// (core/services/workerctl).
+//
+// The non-agent branch is therefore a grant of nothing, and it has to be
+// spelled that way rather than deleted. NATS reads an EMPTY allow list as no
+// restriction, so a function that returned nil here would upgrade every JWT the
+// frontend still mints for a backend node from "its own inbox" to "the entire
+// account". The inbox is self-scoped and reaches no cluster subject.
func WorkerPermissions(nodeID, nodeType string) (pubAllow, subAllow []string) {
tok := workerSubjectToken(nodeID)
prefix := "nodes." + tok
@@ -38,16 +50,11 @@ func WorkerPermissions(nodeID, nodeType string) (pubAllow, subAllow []string) {
"_INBOX.>",
}
default:
- // Backend worker: lifecycle + file staging on this node only.
- subAllow = []string{
- prefix + ".>",
- "_INBOX.>",
- }
- pubAllow = []string{
- prefix + ".backend.install.*.progress",
- prefix + ".files.>",
- "_INBOX.>",
- }
+ // Backend worker: nothing, held open at its own inbox for the reason in
+ // the doc comment. The node subtree it used to subscribe on went with
+ // the connection itself, which this worker no longer opens.
+ subAllow = []string{"_INBOX.>"}
+ pubAllow = []string{"_INBOX.>"}
}
return pubAllow, subAllow
}
diff --git a/pkg/natsauth/permissions_coverage_test.go b/pkg/natsauth/permissions_coverage_test.go
index 05d2fbf0bbee..ab1b993c058b 100644
--- a/pkg/natsauth/permissions_coverage_test.go
+++ b/pkg/natsauth/permissions_coverage_test.go
@@ -33,6 +33,13 @@ func subjectMatches(pattern, subject string) bool {
return len(p) == len(s)
}
+// workerSubjectTokenForTest mirrors the sanitizer both packages implement, so
+// the negative assertion below names the exact prefix without reaching into
+// either package's unexported copy.
+func workerSubjectTokenForTest(nodeID string) string {
+ return strings.NewReplacer(".", "-", "*", "-", ">", "-", " ", "-", "\t", "-", "\n", "-").Replace(nodeID)
+}
+
func anyAllows(allow []string, subject string) bool {
for _, p := range allow {
if subjectMatches(p, subject) {
@@ -52,32 +59,40 @@ var _ = Describe("WorkerPermissions subject coverage", func() {
Context("backend worker", func() {
pub, sub := natsauth.WorkerPermissions(nodeID, "backend")
- // Every subject core/services/worker/{lifecycle,file_staging}.go subscribes to.
- subscribed := []string{
- messaging.SubjectNodeBackendInstall(nodeID),
- messaging.SubjectNodeBackendUpgrade(nodeID),
- messaging.SubjectNodeBackendStop(nodeID),
- messaging.SubjectNodeBackendDelete(nodeID),
- messaging.SubjectNodeBackendList(nodeID),
- messaging.SubjectNodeModelUnload(nodeID),
- messaging.SubjectNodeModelDelete(nodeID),
- messaging.SubjectNodeStop(nodeID),
- messaging.SubjectNodeFilesEnsure(nodeID),
- messaging.SubjectNodeFilesStage(nodeID),
- messaging.SubjectNodeFilesTemp(nodeID),
- messaging.SubjectNodeFilesListDir(nodeID),
- }
- for _, subject := range subscribed {
- It("allows subscribing to "+subject, func() {
- Expect(anyAllows(sub, subject)).To(BeTrue(),
- "backend JWT sub allow-list %v does not cover %s", sub, subject)
- })
- }
+ // A backend worker opens no connection at all on this build. Every verb
+ // a frontend gives it, the backend and model lifecycle ten plus the
+ // four file-staging verbs, is an HTTP route on its tunnelled control
+ // plane, so there is no subject left to cover. See
+ // core/services/workerctl.
+ It("no longer grants a backend worker its own node subtree to subscribe on", func() {
+ Expect(sub).ToNot(ContainElement("nodes."+workerSubjectTokenForTest(nodeID)+".>"),
+ "the node subtree went with the connection the worker no longer opens")
+ })
+
+ // The grant must be a grant of NOTHING and not an ABSENT grant: NATS
+ // treats an empty allow list as no restriction, so a branch that
+ // returned nil would silently widen every backend JWT the frontend
+ // still mints to the whole account. ConsistOf, not BeEmpty, is what
+ // tells those two apart.
+ It("grants a backend worker its own inbox and nothing else", func() {
+ Expect(sub).To(ConsistOf("_INBOX.>"))
+ Expect(sub).ToNot(BeEmpty(),
+ "an empty allow list is unrestricted in NATS, not restrictive")
+ })
+
+ // The negative half, and it is the one that would catch a verb quietly
+ // coming back to the bus: a backend worker is granted nothing to
+ // publish at all beyond its own inbox. File staging used to be the one
+ // exception and is not any more.
+ It("grants a backend worker no publish rights outside its inbox", func() {
+ Expect(pub).To(ConsistOf("_INBOX.>"))
+ Expect(pub).ToNot(BeEmpty(),
+ "an empty allow list is unrestricted in NATS, not restrictive")
+ })
- It("allows publishing backend.install progress", func() {
- subject := messaging.SubjectNodeBackendInstallProgress(nodeID, "op-123")
- Expect(anyAllows(pub, subject)).To(BeTrue(),
- "backend JWT pub allow-list %v does not cover %s", pub, subject)
+ It("no longer grants a backend worker the file-staging publish subtree", func() {
+ Expect(anyAllows(pub, "nodes."+workerSubjectTokenForTest(nodeID)+".files.stage")).To(BeFalse(),
+ "backend JWT pub allow-list %v still covers file staging", pub)
})
})
@@ -115,7 +130,7 @@ var _ = Describe("Documented NATS service-user permissions", func() {
frontendPublishes := []string{
messaging.SubjectPrefixCacheObserve,
messaging.SubjectPrefixCacheInvalidate,
- messaging.SubjectNodeBackendInstall("node-1"),
+ messaging.SubjectNodeBackendStop("node-1"),
messaging.SubjectGalleryProgress("op-1"),
}
diff --git a/scripts/build/healthcheck.sh b/scripts/build/healthcheck.sh
index eff574deda33..d47d16f3b45f 100755
--- a/scripts/build/healthcheck.sh
+++ b/scripts/build/healthcheck.sh
@@ -19,9 +19,9 @@
# 3. The frontend endpoint, when the mode cannot be determined.
#
# Ports are read from environment variables only, which is how containers are
-# configured in practice (compose/k8s set LOCALAI_ADDRESS, LOCALAI_SERVE_ADDR,
-# ...). If you instead pass the bind address as a CLI flag, set
-# HEALTHCHECK_ENDPOINT to match.
+# configured in practice (compose/k8s set LOCALAI_ADDRESS, LOCALAI_ADDR,
+# LOCALAI_SERVE_ADDR, ...). If you instead pass the bind address as a CLI flag,
+# set HEALTHCHECK_ENDPOINT to match.
set -u
# Detect the arguments local-ai was started with. PID 1 is the usual case
@@ -99,9 +99,24 @@ if [ -z "$endpoint" ]; then
# The worker's file-transfer server (which also serves /readyz and
# /healthz) binds LOCALAI_HTTP_ADDR when set, otherwise the gRPC
# base port minus one. See Config.resolveHTTPAddr.
+ #
+ # The base port comes from LOCALAI_ADDR first and LOCALAI_SERVE_ADDR
+ # second, which is Config.effectiveBasePort's own order. Reading
+ # only the second one meant a worker configured with LOCALAI_ADDR
+ # (the documented knob; LOCALAI_SERVE_ADDR is marked hidden) was
+ # probed on the default 50050 while its server sat on a different
+ # port. That is #10987 again: a working worker reporting
+ # `unhealthy` forever because the probe went somewhere nothing
+ # binds.
+ #
+ # The worker binds loopback, which is where this probe runs: it runs
+ # inside the container, so no inbound port is needed for it to work.
port=$(port_of "${LOCALAI_HTTP_ADDR:-}")
if [ -z "$port" ]; then
- base=$(port_of "${LOCALAI_SERVE_ADDR:-}")
+ base=$(port_of "${LOCALAI_ADDR:-}")
+ if [ -z "$base" ]; then
+ base=$(port_of "${LOCALAI_SERVE_ADDR:-}")
+ fi
port=$(( ${base:-50051} - 1 ))
fi
endpoint="http://localhost:${port}/readyz"
diff --git a/scripts/build/healthcheck_test.sh b/scripts/build/healthcheck_test.sh
index 86d2ff098a20..9dc811b8fbf4 100644
--- a/scripts/build/healthcheck_test.sh
+++ b/scripts/build/healthcheck_test.sh
@@ -101,6 +101,21 @@ echo "== worker derives the port from LOCALAI_SERVE_ADDR"
run_hc 0 "local-ai worker" LOCALAI_SERVE_ADDR="0.0.0.0:60000"
expect_url "http://localhost:59999/readyz"
+echo "== worker derives the port from LOCALAI_ADDR"
+# LOCALAI_ADDR is the worker's documented base-port knob (LOCALAI_SERVE_ADDR is
+# hidden), and Config.effectiveBasePort reads it FIRST. A probe that ignored it
+# went to 50050 while the server sat elsewhere, which is #10987's symptom.
+run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000"
+expect_url "http://localhost:59999/readyz"
+
+echo "== LOCALAI_ADDR outranks LOCALAI_SERVE_ADDR, as effectiveBasePort does"
+run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000" LOCALAI_SERVE_ADDR="0.0.0.0:50051"
+expect_url "http://localhost:59999/readyz"
+
+echo "== an explicit LOCALAI_HTTP_ADDR still outranks both"
+run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000" LOCALAI_HTTP_ADDR="0.0.0.0:18081"
+expect_url "http://localhost:18081/readyz"
+
echo "== worker honours an explicit LOCALAI_HTTP_ADDR"
run_hc 0 "local-ai worker" LOCALAI_HTTP_ADDR="0.0.0.0:18080"
expect_url "http://localhost:18080/readyz"
diff --git a/tests/e2e/distributed/backend_logs_test.go b/tests/e2e/distributed/backend_logs_test.go
index 79dea3902d01..a858629ef464 100644
--- a/tests/e2e/distributed/backend_logs_test.go
+++ b/tests/e2e/distributed/backend_logs_test.go
@@ -25,6 +25,42 @@ import (
"gorm.io/gorm/logger"
)
+// waitForSingleLogSubscriber blocks until the worker's WebSocket log handler has
+// registered its subscription on the store.
+//
+// The handler writes the "initial" batch first and subscribes only afterwards,
+// so a line appended the instant that batch lands is buffered but never
+// streamed, and the spec then waits out its full read deadline. Measured at
+// roughly one run in seventeen with `--repeat`, which is far too often for CI.
+// Waiting on the subscription removes the race from the spec; the handler's own
+// snapshot/subscribe window is a separate production question, marked at both
+// production sites.
+//
+// Only valid where BackendLogStore.Subscribe resolves modelID to exactly ONE
+// buffer: a bare model ID with no "#N" replica buffers in the store, or
+// a full process key. Subscribe registers the exact-key buffer and each replica
+// buffer one at a time, so for a model that does have replicas the count goes
+// positive while later replicas are still unattached and the race survives.
+// Hence the assertion is on exactly 1 rather than "at least 1": a spec that
+// misapplies this to a replicated model fails loudly on the count instead of
+// going quietly back to being flaky.
+func waitForSingleLogSubscriber(logStore *model.BackendLogStore, modelID string) {
+ GinkgoHelper()
+ Eventually(func() int { return logStore.SubscriberCount(modelID) }, "10s", "5ms").
+ Should(Equal(1), "the WebSocket handler never subscribed to %q exactly once", modelID)
+}
+
+// directWorkerDialerFor stands in for the worker tunnel in these specs.
+//
+// The log-proxy endpoints reach a worker over the tunnel that worker holds, and
+// refuse to reach one without a dialer. These specs run the worker's HTTP
+// server on loopback, so a plain TCP dial is the stand-in; production supplies
+// the real one from core/application.
+func directWorkerDialerFor(_ string) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext
+}
+
var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func() {
Context("Worker HTTP log endpoints", func() {
@@ -212,6 +248,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
Expect(initialLines[1].Text).To(Equal("line-2"))
// Now append a new line and verify it arrives via WebSocket
+ waitForSingleLogSubscriber(logStore, "ws-model")
logStore.AppendLine("ws-model", "stdout", "line-3-realtime")
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
@@ -280,6 +317,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
Expect(conn.ReadJSON(&initialMsg)).To(Succeed())
// Append line to a different model
+ waitForSingleLogSubscriber(logStore, "ws-model")
logStore.AppendLine("other-model", "stdout", "should not appear")
// Append line to our model
logStore.AppendLine("ws-model", "stdout", "should appear")
@@ -343,7 +381,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
// Create an Echo test server with the proxy endpoint
e := echo.New()
- e.GET("/api/nodes/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, token))
+ e.GET("/api/nodes/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, token, directWorkerDialerFor))
req := httptest.NewRequest("GET", fmt.Sprintf("/api/nodes/%s/backend-logs", node.ID), nil)
rec := httptest.NewRecorder()
@@ -365,7 +403,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
e := echo.New()
- e.GET("/api/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsLinesEndpoint(registry, token))
+ e.GET("/api/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsLinesEndpoint(registry, token, directWorkerDialerFor))
req := httptest.NewRequest("GET", fmt.Sprintf("/api/nodes/%s/backend-logs/remote-model", node.ID), nil)
rec := httptest.NewRecorder()
@@ -382,7 +420,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
It("should return 404 for unknown node ID", func() {
e := echo.New()
- e.GET("/api/nodes/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, token))
+ e.GET("/api/nodes/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, token, directWorkerDialerFor))
req := httptest.NewRequest("GET", "/api/nodes/nonexistent-id/backend-logs", nil)
rec := httptest.NewRecorder()
@@ -426,7 +464,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
// Start Echo server with the WebSocket proxy route
e := echo.New()
- e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, token))
+ e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, token, directWorkerDialerFor))
lis, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
@@ -475,6 +513,7 @@ var _ = Describe("Distributed Backend Log Streaming", Label("Distributed"), func
Expect(initialLines[0].Text).To(Equal("initial line from worker"))
// Append a new line on the worker's log store
+ waitForSingleLogSubscriber(logStore, "proxy-model")
logStore.AppendLine("proxy-model", "stderr", "realtime via proxy")
// Read the streamed line through the proxy
diff --git a/tests/e2e/distributed/cluster/admin.go b/tests/e2e/distributed/cluster/admin.go
new file mode 100644
index 000000000000..cbecf8c37eff
--- /dev/null
+++ b/tests/e2e/distributed/cluster/admin.go
@@ -0,0 +1,208 @@
+package cluster
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/httpclient"
+)
+
+const (
+ // adminPassword is sent with "acknowledge_weak_password": true, which sets
+ // PasswordPolicy{AllowWeak: true} and skips the length floor and the zxcvbn
+ // score entirely (core/http/auth/password.go). Only the technical
+ // invariants still apply: non-empty, at most 72 bytes, no NUL. The
+ // acknowledgement is deliberate rather than incidental, so a future
+ // tightening of the policy cannot break every failover spec at setup time.
+ adminPassword = "e2e-admin-password"
+ // sessionCookieName mirrors the unexported constant in core/http/auth.
+ // The register handler returns 201 both for "user created, here is your
+ // session" and for "this email already exists" (a deliberate account
+ // enumeration defence), so the status code alone cannot tell the two
+ // apart: the presence of this cookie is the only reliable signal.
+ sessionCookieName = "session"
+ // authRequestTimeout bounds one register/login round trip.
+ authRequestTimeout = 30 * time.Second
+ // bodyExcerptLimit caps how much of an error response is quoted back.
+ bodyExcerptLimit = 512
+)
+
+// ForTestingEmpty returns a Cluster with no processes. It exists so the package's
+// own argument-validation specs do not need to start anything.
+func ForTestingEmpty() *Cluster {
+ return &Cluster{}
+}
+
+// AdminSession registers the admin user on frontend i and returns a client
+// carrying the resulting session cookie. The email matches LOCALAI_ADMIN_EMAIL,
+// which core/http/auth exempts from the approval gate and assigns the admin
+// role, so registration alone yields an active admin session.
+//
+// Call this ONCE per cluster and share the client. Two reasons:
+//
+// One, a single rate limiter of 5 requests per minute per client IP guards
+// POST /api/auth/token-login, POST /api/auth/register, POST /api/auth/login AND
+// PUT /api/auth/password (core/http/routes/auth.go:190). They share one budget,
+// and every e2e request arrives from 127.0.0.1, so a spec that changes a
+// password spends from the same five.
+//
+// Two, the returned client is already good for every frontend: sessions live in
+// the shared Postgres auth DB, the harness pins one HMAC secret across replicas
+// so the session row resolves at any of them, and Go's cookie jar keys cookies
+// by host without the port.
+func (c *Cluster) AdminSession(i int) (*http.Client, error) {
+ base, err := c.frontendBaseURL(i)
+ if err != nil {
+ return nil, err
+ }
+
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating cookie jar: %w", err)
+ }
+ // httpclient hardens the transport and refuses redirects; the jar is the one
+ // thing it does not configure, and a session cookie is the whole point here.
+ client := httpclient.NewWithTimeout(authRequestTimeout)
+ client.Jar = jar
+
+ credentials := map[string]any{
+ "email": c.opts.AdminEmail,
+ "password": adminPassword,
+ }
+ registration := map[string]any{
+ "email": c.opts.AdminEmail,
+ "password": adminPassword,
+ "name": "E2E Admin",
+ "acknowledge_weak_password": true,
+ }
+
+ registerStatus, registerBody, err := postJSON(client, base+"/api/auth/register", registration)
+ if err != nil {
+ return nil, fmt.Errorf("registering admin on frontend %d: %w", i, err)
+ }
+ if hasSessionCookie(jar, base) {
+ return client, nil
+ }
+
+ // No cookie means the user already existed (a repeat call against the same
+ // Postgres), or registration was rejected. Log in; on failure the
+ // registration response is the diagnosis, so carry it into the error.
+ loginStatus, loginBody, err := postJSON(client, base+"/api/auth/login", credentials)
+ if err != nil {
+ return nil, fmt.Errorf("logging in admin on frontend %d: %w", i, err)
+ }
+ if loginStatus != http.StatusOK {
+ return nil, fmt.Errorf(
+ "admin login on frontend %d returned %d (%s); registration had returned %d (%s)",
+ i, loginStatus, loginBody, registerStatus, registerBody)
+ }
+ if !hasSessionCookie(jar, base) {
+ return nil, fmt.Errorf("admin login on frontend %d returned 200 but set no %q cookie: %s", i, sessionCookieName, loginBody)
+ }
+ return client, nil
+}
+
+// GetJSON performs an authenticated GET against a frontend and decodes the body.
+func (c *Cluster) GetJSON(client *http.Client, frontend int, path string, out any) error {
+ base, err := c.frontendBaseURL(frontend)
+ if err != nil {
+ return err
+ }
+ resp, err := client.Get(base + path)
+ if err != nil {
+ return fmt.Errorf("GET %s on frontend %d: %w", path, frontend, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("GET %s on frontend %d returned %d: %s", path, frontend, resp.StatusCode, excerpt(resp.Body))
+ }
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return fmt.Errorf("decoding %s from frontend %d: %w", path, frontend, err)
+ }
+ return nil
+}
+
+// PostJSON performs an authenticated POST against a frontend and decodes the
+// body, reporting the status it got so a caller can act on it.
+//
+// It returns the status rather than requiring 200 the way GetJSON does,
+// because the control-plane endpoints a spec drives through here answer 202
+// (an install was accepted and runs asynchronously) and the failure cases a
+// negative control is about are statuses, not transport errors. out may be nil
+// for a caller that only wants the status.
+func (c *Cluster) PostJSON(client *http.Client, frontend int, path string, body any, out any) (int, error) {
+ base, err := c.frontendBaseURL(frontend)
+ if err != nil {
+ return 0, err
+ }
+ encoded, err := json.Marshal(body)
+ if err != nil {
+ return 0, fmt.Errorf("marshalling the body for %s: %w", path, err)
+ }
+ resp, err := client.Post(base+path, "application/json", bytes.NewReader(encoded))
+ if err != nil {
+ return 0, fmt.Errorf("POST %s on frontend %d: %w", path, frontend, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if out == nil {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, bodyExcerptLimit))
+ return resp.StatusCode, nil
+ }
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return resp.StatusCode, fmt.Errorf("decoding the %s response from frontend %d (status %d): %w", path, frontend, resp.StatusCode, err)
+ }
+ return resp.StatusCode, nil
+}
+
+// frontendBaseURL validates the index before FrontendURL indexes the slice: a
+// bare index panic in a helper every failover spec calls is far harder to read
+// than a named error.
+func (c *Cluster) frontendBaseURL(i int) (string, error) {
+ if i < 0 || i >= len(c.frontends) {
+ return "", fmt.Errorf("frontend %d out of range (cluster has %d)", i, len(c.frontends))
+ }
+ return c.FrontendURL(i), nil
+}
+
+// postJSON sends body as JSON and returns the status plus an excerpt of the
+// response, closing the body in every path.
+func postJSON(client *http.Client, endpoint string, body any) (int, string, error) {
+ encoded, err := json.Marshal(body)
+ if err != nil {
+ return 0, "", fmt.Errorf("marshalling request body: %w", err)
+ }
+ resp, err := client.Post(endpoint, "application/json", bytes.NewReader(encoded))
+ if err != nil {
+ return 0, "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ return resp.StatusCode, excerpt(resp.Body), nil
+}
+
+// hasSessionCookie reports whether the jar holds a usable session for base.
+func hasSessionCookie(jar *cookiejar.Jar, base string) bool {
+ u, err := url.Parse(base)
+ if err != nil {
+ return false
+ }
+ for _, cookie := range jar.Cookies(u) {
+ if cookie.Name == sessionCookieName && cookie.Value != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func excerpt(r io.Reader) string {
+ data, err := io.ReadAll(io.LimitReader(r, bodyExcerptLimit))
+ if err != nil {
+ return fmt.Sprintf("", err)
+ }
+ return string(bytes.TrimSpace(data))
+}
diff --git a/tests/e2e/distributed/cluster/cluster.go b/tests/e2e/distributed/cluster/cluster.go
new file mode 100644
index 000000000000..0c98421d28fb
--- /dev/null
+++ b/tests/e2e/distributed/cluster/cluster.go
@@ -0,0 +1,732 @@
+// Package cluster runs LocalAI as real child processes for end-to-end tests.
+//
+// The in-process suites cannot express frontend-replica failure: there is no
+// process to kill, no second replica to race, and no real HTTP boundary between
+// a worker and the frontend it registered with. This package starts the same
+// binary an operator runs, one process per frontend replica and one per worker,
+// against containerised Postgres and NATS.
+package cluster
+
+import (
+ "fmt"
+ "math/rand/v2"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/httpclient"
+
+ "github.com/phayes/freeport"
+)
+
+// Options configures a cluster. Every field without a default is required.
+type Options struct {
+ // Binary is the path to a built local-ai.
+ Binary string
+ // MockBackend is the path to the mock-backend binary. When set it is copied
+ // into each worker's backends directory as "mock-backend", which is the name
+ // model YAML refers to (see tests/e2e/e2e_suite_test.go:75).
+ MockBackend string
+ // PGDSN and NatsURL point at infrastructure the caller already started.
+ PGDSN string
+ NatsURL string
+ // LogDir receives one file per process. Never empty: a cluster failure is
+ // unreadable without them.
+ LogDir string
+
+ RegistrationToken string // default "e2e-token"
+ AdminEmail string // default "admin@e2e.local"
+
+ Frontends int
+ Workers int
+
+ // AgentWorkers is how many `local-ai agent-worker` processes to start
+ // alongside the backend workers.
+ //
+ // They exist so a spec can hold the two kinds of worker side by side in one
+ // cluster. An agent worker still speaks NATS and holds no tunnel at all,
+ // which is exactly the shape the tunnel-departure rules must not act on: it
+ // has no node_connections row, so its presence is PresenceUnknown forever.
+ // A spec that asserted only on backend workers could not tell "agent
+ // workers are unaffected" from "nothing here looks at them".
+ //
+ // They register through the same WorkerFrontendURL hook as backend workers,
+ // so a spec that puts a balancer in front of the fleet gets one for its
+ // agent workers too. Without that, killing a replica would orphan the agent
+ // worker's heartbeats and it would go unhealthy for a reason that has
+ // nothing to do with what the spec is about.
+ AgentWorkers int
+
+ // ReconnectGrace sets LOCALAI_WORKER_RECONNECT_GRACE on every frontend: how
+ // long a worker whose tunnel was lost is read as reconnecting rather than
+ // gone. Zero leaves the binary's own default (90s).
+ //
+ // It is an Option rather than a per-spec environment edit because two
+ // specs need it pulled in OPPOSITE directions and neither can use the
+ // default: one has to prove nothing is reaped inside the window and needs
+ // it longer than a re-home takes, and one has to prove a departed worker
+ // stops being reported healthy and would otherwise wait 90 seconds to say
+ // so.
+ ReconnectGrace time.Duration
+
+ // SpreadWorkerRegistrations sends worker i to frontend i%Frontends instead
+ // of sending every worker to frontend 0.
+ //
+ // Off by default, and deliberately so: the cross-replica session specs read
+ // a node at frontend 1 that only frontend 0 was ever told about, and
+ // spreading registrations would leave them passing while proving nothing.
+ // It exists for the racing-replicas spec, which is about two replicas
+ // writing to one roster concurrently and cannot express that at all while
+ // every worker registers through the same process.
+ SpreadWorkerRegistrations bool
+
+ // Models is written into every frontend's models directory before that
+ // replica starts, keyed by file name. It is how a spec gets a model
+ // configuration in front of the frontend at all: the models directory is
+ // scanned at startup, so a file written afterwards is not guaranteed to be
+ // seen, and there is no admin endpoint that creates a config.
+ //
+ // Frontends only. A worker is handed model artifacts by the frontend's file
+ // staging, over the tunnel, and pre-seeding the worker would hide whether
+ // that worked.
+ Models map[string]string
+
+ // WorkerFrontendURL rewrites the URL worker i registers and holds its
+ // tunnel against. It is called once per worker, after every frontend is
+ // serving, and is given the URL the worker would otherwise have been handed
+ // plus every frontend's URL in index order, so a hook can put a proxy or a
+ // load balancer in front of one replica or of all of them.
+ //
+ // It exists for two things the fixed per-replica URL cannot express. One is
+ // a worker that survives its replica: LOCALAI_REGISTER_TO is resolved once
+ // at boot and is the tunnel endpoint as well as the registration one, so a
+ // worker pointed straight at a replica has nowhere to reconnect to when
+ // that replica dies, and the re-home this feature is built on cannot
+ // happen. The other is the suite's negative control, which needs a worker
+ // that registers, heartbeats and reports healthy exactly as usual while its
+ // tunnel dial never reaches a frontend; nothing else can produce that,
+ // because LOCALAI_WORKER_TUNNEL=false is refused at startup and a worker
+ // that never started proves nothing about a worker reachable some other
+ // way.
+ WorkerFrontendURL func(worker int, registrar string, frontends []string) string
+}
+
+// Process is one running local-ai.
+type Process struct {
+ Name string
+ // Cmd is exposed for signalling only. Never call Cmd.Wait on it: the reaper
+ // goroutine started in spawn owns it, a second Wait races the first, and
+ // waitErr is only safe to read after <-p.exited.
+ Cmd *exec.Cmd
+ Port int
+ LogPath string
+
+ logFile *os.File
+ // exited closes once the reaper has collected the process. Only the reaper
+ // calls Cmd.Wait, so nothing else may: a second Wait on the same Cmd races
+ // the first and corrupts ProcessState.
+ //
+ // A closed exited proves the child is gone. An open one proves nothing: it
+ // is still open for the whole interval between the child exiting and waitid
+ // collecting it, during which the child is a zombie that signal 0 reports as
+ // alive. Anything asserting on a process being dead must poll, not sample.
+ exited chan struct{}
+ waitErr error
+}
+
+// Cluster is a running set of frontend and worker processes.
+type Cluster struct {
+ opts Options
+ frontends []*Process
+ workers []*Process
+ // agentWorkers are kept apart from workers rather than appended to it. Every
+ // index-taking method on this type means "backend worker i", and folding the
+ // two together would silently renumber them for every existing spec.
+ agentWorkers []*Process
+ baseDir string
+}
+
+const (
+ defaultRegistrationToken = "e2e-token"
+ defaultAdminEmail = "admin@e2e.local"
+ // testHMACSecret is shared by every frontend so a session minted at one
+ // replica validates at all of them. See the note in startFrontend.
+ testHMACSecret = "e2e-cluster-hmac-secret"
+ readinessTimeout = 90 * time.Second
+ readinessPoll = 200 * time.Millisecond
+ // processExitTimeout bounds the post-SIGKILL wait in terminate. An unbounded
+ // wait turns one stuck child (D state, or a Wait that never returns) into a
+ // suite-wide Ginkgo timeout that names nothing.
+ processExitTimeout = 10 * time.Second
+)
+
+func (o *Options) applyDefaults() {
+ if o.RegistrationToken == "" {
+ o.RegistrationToken = defaultRegistrationToken
+ }
+ if o.AdminEmail == "" {
+ o.AdminEmail = defaultAdminEmail
+ }
+}
+
+func (o Options) validate() error {
+ if o.Frontends < 1 {
+ return fmt.Errorf("cluster needs at least one frontend, got %d", o.Frontends)
+ }
+ if o.LogDir == "" {
+ return fmt.Errorf("cluster needs a LogDir: process logs are the only way to read a cluster failure")
+ }
+ if st, err := os.Stat(o.Binary); err != nil || st.IsDir() {
+ return fmt.Errorf("local-ai binary not found at %q (run: make build)", o.Binary)
+ }
+ return nil
+}
+
+// Start brings up the cluster. It blocks until every frontend answers /readyz
+// and every worker process has been spawned. It does NOT wait for workers to
+// register: that needs an authenticated admin session, so a caller that depends
+// on registration must poll /api/nodes itself.
+func Start(opts Options) (*Cluster, error) {
+ opts.applyDefaults()
+ if err := opts.validate(); err != nil {
+ return nil, err
+ }
+
+ baseDir, err := os.MkdirTemp("", "localai-cluster-*")
+ if err != nil {
+ return nil, fmt.Errorf("creating cluster work dir: %w", err)
+ }
+
+ c := &Cluster{opts: opts, baseDir: baseDir}
+
+ for i := 0; i < opts.Frontends; i++ {
+ p, err := c.startFrontend(i, 0)
+ if err != nil {
+ c.Stop()
+ return nil, err
+ }
+ c.frontends = append(c.frontends, p)
+ }
+ for i := 0; i < opts.Workers; i++ {
+ p, err := c.startWorker(i)
+ if err != nil {
+ c.Stop()
+ return nil, err
+ }
+ c.workers = append(c.workers, p)
+ }
+ for i := 0; i < opts.AgentWorkers; i++ {
+ p, err := c.startAgentWorker(i)
+ if err != nil {
+ c.Stop()
+ return nil, err
+ }
+ c.agentWorkers = append(c.agentWorkers, p)
+ }
+ return c, nil
+}
+
+// startFrontend starts frontend i. A port <= 0 allocates a fresh one; a pinned
+// port exists for restart: workers take LOCALAI_REGISTER_TO once at boot and
+// never re-resolve it, so a replica that comes back on a new port is
+// unreachable by exactly the workers that registered with it.
+func (c *Cluster) startFrontend(i int, port int) (*Process, error) {
+ if port <= 0 {
+ allocated, err := freeport.GetFreePort()
+ if err != nil {
+ return nil, fmt.Errorf("allocating frontend port: %w", err)
+ }
+ port = allocated
+ }
+ name := frontendName(i)
+ dir := c.frontendDir(i)
+ if err := os.MkdirAll(filepath.Join(dir, "models"), 0o755); err != nil {
+ return nil, fmt.Errorf("creating %s dirs: %w", name, err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "backends"), 0o755); err != nil {
+ return nil, fmt.Errorf("creating %s dirs: %w", name, err)
+ }
+ // Without an explicit LOCALAI_DATA_PATH every child resolves DataPath to
+ // ${cwd}/data (core/cli/run.go:48), which under `go test` is inside the
+ // source tree and shared by every replica: one collectiondb, one task and
+ // job store for processes that are meant to be independent.
+ dataPath := c.frontendDataDir(i)
+ if err := os.MkdirAll(dataPath, 0o750); err != nil {
+ return nil, fmt.Errorf("creating %s dirs: %w", name, err)
+ }
+ for file, content := range c.opts.Models {
+ path := filepath.Join(dir, "models", file)
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ return nil, fmt.Errorf("writing %s for %s: %w", file, name, err)
+ }
+ }
+
+ cmd := exec.Command(c.opts.Binary, "run",
+ "--address", fmt.Sprintf("127.0.0.1:%d", port),
+ "--models-path", filepath.Join(dir, "models"),
+ "--backends-path", filepath.Join(dir, "backends"),
+ )
+ // Cmd.Environ() is the parent environment this Cmd would already run with;
+ // the children need PATH, HOME and the Go/CI environment intact.
+ cmd.Env = append(cmd.Environ(),
+ "LOCALAI_DISTRIBUTED=true",
+ "LOCALAI_NATS_URL="+c.opts.NatsURL,
+ "LOCALAI_AUTH=true",
+ "LOCALAI_AUTH_DATABASE_URL="+c.opts.PGDSN,
+ "LOCALAI_ADMIN_EMAIL="+c.opts.AdminEmail,
+ "LOCALAI_DATA_PATH="+dataPath,
+ // Session rows are keyed by HMAC-SHA256(token, APIKeyHMACSecret), and
+ // the secret is generated per instance into {DataPath}/.hmac_secret
+ // unless pinned (core/application/startup.go:141-148). Now that each
+ // replica owns its data directory, an unpinned secret would differ per
+ // replica, so the cookie minted at frontend 0 would hash to a session
+ // row that does not exist at frontend 1 and every post-failover
+ // /api/nodes call would 401 with nothing in the logs to explain it.
+ // Pinning makes the cross-replica session a property of the harness.
+ "LOCALAI_AUTH_HMAC_SECRET="+testHMACSecret,
+ "LOCALAI_REGISTRATION_TOKEN="+c.opts.RegistrationToken,
+ // Every replica here shares one host, so the address a peer dials is
+ // this process's own loopback address. It has to be said explicitly:
+ // the automatic discovery asks which local address routes to
+ // PostgreSQL, and this suite's PostgreSQL is a container published on
+ // 127.0.0.1, so the discovery refuses (correctly) rather than
+ // advertising a loopback address that would mean "yourself" on a
+ // multi-host deployment.
+ fmt.Sprintf("LOCALAI_DISTRIBUTED_ADVERTISE_ADDR=127.0.0.1:%d", port),
+ "LOCALAI_AUTO_APPROVE_NODES=true",
+ "DEBUG=true",
+ )
+ if c.opts.ReconnectGrace > 0 {
+ cmd.Env = append(cmd.Env, "LOCALAI_WORKER_RECONNECT_GRACE="+c.opts.ReconnectGrace.String())
+ }
+
+ p, err := c.spawn(name, cmd, port)
+ if err != nil {
+ return nil, err
+ }
+ if err := waitReady(p, fmt.Sprintf("http://127.0.0.1:%d/readyz", port)); err != nil {
+ // The caller never sees this process, so Stop() will never reach it:
+ // reap it here or it outlives the suite holding a port and a log handle.
+ p.terminate()
+ return nil, fmt.Errorf("%s never became ready (see %s): %w", name, p.LogPath, err)
+ }
+ return p, nil
+}
+
+func (c *Cluster) startWorker(i int) (*Process, error) {
+ // One contiguous block, laid out the way production lays it out. See
+ // reserveWorkerPorts.
+ grpcPort, err := reserveWorkerPorts()
+ if err != nil {
+ return nil, fmt.Errorf("allocating worker ports: %w", err)
+ }
+ httpPort := grpcPort - 1
+ maxPort := grpcPort + workerPortBlockSize - 1
+ name := fmt.Sprintf("worker-%d", i)
+ dir := filepath.Join(c.baseDir, name)
+ backends := filepath.Join(dir, "backends")
+ if err := os.MkdirAll(filepath.Join(dir, "models"), 0o755); err != nil {
+ return nil, fmt.Errorf("creating %s dirs: %w", name, err)
+ }
+ if err := os.MkdirAll(backends, 0o755); err != nil {
+ return nil, fmt.Errorf("creating %s dirs: %w", name, err)
+ }
+ if c.opts.MockBackend != "" {
+ if err := copyExecutable(c.opts.MockBackend, filepath.Join(backends, "mock-backend")); err != nil {
+ return nil, fmt.Errorf("installing mock backend for %s: %w", name, err)
+ }
+ }
+
+ cmd := exec.Command(c.opts.Binary, "worker",
+ "--models-path", filepath.Join(dir, "models"),
+ "--backends-path", backends,
+ )
+ cmd.Env = append(cmd.Environ(),
+ // Ports only. A worker advertises nothing, so there is no advertise
+ // address to set; these exist to keep concurrently running workers off
+ // each other's ports, not to make anything reachable. Every bind is
+ // loopback whatever is set here.
+ //
+ // The max port is what keeps the backend allocator inside the block
+ // reserved for this worker. Without it the allocator walks upward to
+ // 65535 (core/services/worker/registration.go, effectiveMaxPort), so a
+ // worker running enough backends walks straight out of its block and
+ // into whatever else this host is using.
+ fmt.Sprintf("LOCALAI_SERVE_ADDR=127.0.0.1:%d", grpcPort),
+ fmt.Sprintf("LOCALAI_HTTP_ADDR=127.0.0.1:%d", httpPort),
+ fmt.Sprintf("LOCALAI_GRPC_MAX_PORT=%d", maxPort),
+ // Workers register with frontend 0 ONLY unless the caller opts into
+ // SpreadWorkerRegistrations, and the cross-replica session specs depend
+ // on that default. They prove a session minted at frontend 0 resolves at
+ // frontend 1 by reading a node that only frontend 0 was ever told about;
+ // register the worker everywhere and they still pass while proving
+ // nothing.
+ //
+ // Nothing in those specs can detect the change. The registry keys nodes
+ // by name and preserves ids across the shared Postgres
+ // (core/services/nodes/registry.go:522-527), so a roster read at
+ // frontend 1 looks identical either way. Anyone changing the default
+ // here must revisit tests/e2e/distributed/cluster_baseline_test.go by
+ // hand.
+ //
+ // The registrar also fixes where this worker's heartbeats go for the
+ // rest of its life: the loop posts to the URL it was given at boot and
+ // never re-resolves it (core/cli/workerregistry/client.go), so killing a
+ // worker's registrar orphans that worker rather than failing it over.
+ //
+ // No LOCALAI_NATS_URL: a backend worker connects to no bus, and passing
+ // one would make every spec here prove the tunnel-only path works while
+ // quietly handing the worker the thing it is supposed to do without.
+ // The frontends above still get it.
+ "LOCALAI_REGISTER_TO="+c.workerFrontendURL(i),
+ "LOCALAI_NODE_NAME="+name,
+ "LOCALAI_REGISTRATION_TOKEN="+c.opts.RegistrationToken,
+ "DEBUG=true",
+ )
+
+ return c.spawn(name, cmd, grpcPort)
+}
+
+// startAgentWorker starts agent worker i.
+//
+// It is `local-ai agent-worker`, not `local-ai worker`, and the difference is
+// the whole point of having it here: an agent worker REQUIRES a NATS URL, dials
+// no tunnel, and runs no backend, so it is the control for every rule this
+// phase added about a worker whose tunnel is gone. It binds nothing, so there
+// is no port to reserve and no readiness endpoint to wait on; a spec learns it
+// is up by finding it in the roster.
+func (c *Cluster) startAgentWorker(i int) (*Process, error) {
+ name := agentWorkerName(i)
+ cmd := exec.Command(c.opts.Binary, "agent-worker")
+ cmd.Env = append(cmd.Environ(),
+ // The bus, which is what makes this worker the control: a backend
+ // worker in this same cluster is given none.
+ "LOCALAI_NATS_URL="+c.opts.NatsURL,
+ "LOCALAI_REGISTER_TO="+c.workerFrontendURL(i),
+ "LOCALAI_NODE_NAME="+name,
+ "LOCALAI_REGISTRATION_TOKEN="+c.opts.RegistrationToken,
+ "DEBUG=true",
+ )
+ return c.spawn(name, cmd, 0)
+}
+
+// WorkerEnviron is the environment of worker i's RUNNING PROCESS, read from
+// /proc.
+//
+// Not Cmd.Env, deliberately. A spec asserting that a worker runs with no bus
+// URL is asserting about the process, and Cmd.Env is the harness telling the
+// spec what the harness meant to do: the two agree by construction, so a spec
+// reading it proves the harness consistent with itself and nothing about the
+// binary. /proc//environ is what the kernel handed the process.
+//
+// Linux only, which this package already is (it signals with syscall.SIGKILL
+// and reserves ports by binding loopback). A platform without /proc returns the
+// read error rather than falling back to Cmd.Env, so the assertion fails loudly
+// instead of quietly becoming the weaker one.
+func (c *Cluster) WorkerEnviron(i int) ([]string, error) {
+ if err := c.checkWorkerIndex(i); err != nil {
+ return nil, err
+ }
+ p := c.workers[i]
+ if p == nil || p.Cmd == nil || p.Cmd.Process == nil {
+ return nil, fmt.Errorf("worker %d is not running", i)
+ }
+ raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", p.Cmd.Process.Pid))
+ if err != nil {
+ return nil, fmt.Errorf("reading the environment of %s from /proc: %w", p.Name, err)
+ }
+ // NUL separated, with a trailing NUL on a non-empty environment.
+ entries := strings.Split(string(raw), "\x00")
+ out := make([]string, 0, len(entries))
+ for _, e := range entries {
+ if e != "" {
+ out = append(out, e)
+ }
+ }
+ return out, nil
+}
+
+// AgentWorkerName is the node name agent worker i registered under.
+func (c *Cluster) AgentWorkerName(i int) string {
+ if i < 0 || i >= len(c.agentWorkers) {
+ return ""
+ }
+ return c.agentWorkers[i].Name
+}
+
+func agentWorkerName(i int) string {
+ return fmt.Sprintf("agent-worker-%d", i)
+}
+
+const (
+ // workerPortBlockSize is how many ports one worker reserves: one for its
+ // HTTP file-transfer server and the rest for backend processes. A spec that
+ // loads more models than this on one worker exhausts the allocator, which
+ // fails the backend start by name (ErrNoFreePort) instead of colliding.
+ workerPortBlockSize = 24
+
+ // Workers take their ports from BELOW the ephemeral range, which on Linux
+ // starts at 32768 by default. That is not tidiness. Ports the kernel hands
+ // out for outbound connections are exactly the ports a long-lived process
+ // full of outbound connections is liable to be holding when a backend tries
+ // to bind one, and this suite's workers hold a tunnel, a NATS connection
+ // and a registration client each.
+ workerPortFloor = 20000
+ workerPortCeiling = 31000
+
+ // workerPortAttempts bounds the search for a free block before giving up.
+ workerPortAttempts = 200
+)
+
+// reserveWorkerPorts returns the base gRPC port of a contiguous run of ports
+// nothing is currently listening on, laid out the way a real worker lays them
+// out: the HTTP file-transfer server at base-1, and backend processes upward
+// from base.
+//
+// A contiguous block rather than two independent freeport allocations, and the
+// difference is a defect this suite actually hit. The allocator hands backend
+// processes basePort, basePort+1, basePort+2 and so on with no check that
+// anything else holds them (core/services/worker/supervisor.go, allocatePort),
+// so the moment freeport returned two ADJACENT ports the second backend started
+// on a worker was handed the worker's own HTTP server's port and died at
+// startup with "address already in use". freeport returns adjacent ports often,
+// and no spec started two backends on one worker until the tunnel load
+// measurement did, so it presented as a spec that failed about one run in
+// three.
+//
+// This is not race free and cannot be: the probe closes each listener before
+// the worker binds it. It removes the deterministic self-collision, keeps the
+// block out of the range the kernel allocates from, and bounds the allocator to
+// the block, which together is the difference between "sometimes" and "not
+// observed".
+func reserveWorkerPorts() (int, error) {
+ for attempt := 0; attempt < workerPortAttempts; attempt++ {
+ base := workerPortFloor + rand.IntN(workerPortCeiling-workerPortFloor)
+ if blockIsFree(base-1, workerPortBlockSize+1) {
+ return base, nil
+ }
+ }
+ return 0, fmt.Errorf("no free run of %d ports in [%d, %d) after %d attempts",
+ workerPortBlockSize+1, workerPortFloor, workerPortCeiling, workerPortAttempts)
+}
+
+// blockIsFree reports whether count ports from first can all be bound on
+// loopback right now. Every listener is held until the whole run is proven, so
+// a run is not accepted on the strength of one port that a previous iteration
+// of this same loop had just released.
+func blockIsFree(first, count int) bool {
+ held := make([]net.Listener, 0, count)
+ defer func() {
+ for _, l := range held {
+ _ = l.Close()
+ }
+ }()
+ for port := first; port < first+count; port++ {
+ l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ return false
+ }
+ held = append(held, l)
+ }
+ return true
+}
+
+// workerFrontendURL is the URL worker i is told to register and tunnel
+// against: its registrar's, unless the caller installed a hook.
+func (c *Cluster) workerFrontendURL(i int) string {
+ registrar := c.FrontendURL(c.registrarFor(i))
+ if c.opts.WorkerFrontendURL == nil {
+ return registrar
+ }
+ frontends := make([]string, 0, len(c.frontends))
+ for index := range c.frontends {
+ frontends = append(frontends, c.FrontendURL(index))
+ }
+ return c.opts.WorkerFrontendURL(i, registrar, frontends)
+}
+
+func (c *Cluster) spawn(name string, cmd *exec.Cmd, port int) (*Process, error) {
+ logPath := filepath.Join(c.opts.LogDir, name+".log")
+ // Append rather than truncate: a restarted process reopens the same path, and
+ // the log of the instance that died is the one a failover post-mortem needs.
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("creating log file for %s: %w", name, err)
+ }
+ cmd.Stdout = f
+ cmd.Stderr = f
+ if err := cmd.Start(); err != nil {
+ _ = f.Close()
+ return nil, fmt.Errorf("starting %s: %w", name, err)
+ }
+ p := &Process{Name: name, Cmd: cmd, Port: port, LogPath: logPath, logFile: f, exited: make(chan struct{})}
+ // One reaper per process, joined by terminate(): a child that dies on its own
+ // is collected immediately, so waiters learn about it instead of polling a
+ // dead port until the readiness timeout.
+ go func() {
+ p.waitErr = cmd.Wait()
+ close(p.exited)
+ }()
+ return p, nil
+}
+
+// terminate kills the process, waits for the reaper, and releases the log
+// handle. Safe to call more than once and on a process that already exited.
+func (p *Process) terminate() {
+ if p == nil || p.Cmd == nil || p.Cmd.Process == nil {
+ return
+ }
+ _ = p.Cmd.Process.Kill()
+ select {
+ case <-p.exited:
+ case <-time.After(processExitTimeout):
+ fmt.Printf("warning: %s did not exit within %s after SIGKILL; continuing teardown\n", p.Name, processExitTimeout)
+ }
+ if p.logFile != nil {
+ _ = p.logFile.Close()
+ }
+}
+
+// FrontendURL is the base URL of frontend i.
+func (c *Cluster) FrontendURL(i int) string {
+ return fmt.Sprintf("http://127.0.0.1:%d", c.frontends[i].Port)
+}
+
+// RegistrationToken is the shared secret this cluster was started with. It
+// authenticates worker registration AND the replica-to-replica peer link, so a
+// spec acting as a peer needs it rather than a second literal that can drift
+// from Options.
+func (c *Cluster) RegistrationToken() string {
+ return c.opts.RegistrationToken
+}
+
+// FrontendBackendsDir is the directory frontend i installs its OWN backends
+// into.
+//
+// It is exported for one assertion, and a filesystem one rather than an API
+// one: a spec proving a node backend listing came from the WORKER has to show
+// the frontend that answered does not have that backend itself, and the
+// /backends endpoint cannot say so, because in distributed mode it reports the
+// cluster's backends rather than this process's.
+func (c *Cluster) FrontendBackendsDir(i int) (string, error) {
+ if err := c.checkFrontendIndex(i); err != nil {
+ return "", err
+ }
+ return filepath.Join(c.frontendDir(i), "backends"), nil
+}
+
+// NatsURL is the bus this cluster's frontends were given.
+//
+// It is exported for one assertion: a spec proving a WORKER runs with no bus
+// has to show the deployment it joined has one, or "no NATS anywhere" would
+// satisfy it just as well.
+func (c *Cluster) NatsURL() string {
+ return c.opts.NatsURL
+}
+
+// WorkerName is the node name worker i registered under.
+func (c *Cluster) WorkerName(i int) string {
+ return c.workers[i].Name
+}
+
+// registrarFor is the frontend index worker i registers and heartbeats with.
+//
+// It is read at spawn time and baked into the worker's environment, so it is
+// also the answer to "which replica's death orphans this worker".
+func (c *Cluster) registrarFor(worker int) int {
+ if !c.opts.SpreadWorkerRegistrations || c.opts.Frontends < 1 {
+ return 0
+ }
+ return worker % c.opts.Frontends
+}
+
+// WorkerRegistrar is registrarFor, exported so a spec can say which replica it
+// is about to kill relative to a worker instead of re-deriving the rule.
+//
+// It returns an error rather than indexing blindly, like every other exported
+// method here that takes an index. Gomega treats the trailing error as one that
+// must be nil, so Expect(c.WorkerRegistrar(0)).To(...) reads unchanged at the
+// call site while an out-of-range index fails the spec by name instead of
+// silently answering 0, which is a real frontend index and would send a spec
+// off to kill the wrong replica.
+func (c *Cluster) WorkerRegistrar(worker int) (int, error) {
+ if err := c.checkWorkerIndex(worker); err != nil {
+ return 0, err
+ }
+ return c.registrarFor(worker), nil
+}
+
+// Stop terminates every process and removes the work directory. Logs survive in
+// LogDir, which the caller owns.
+func (c *Cluster) Stop() {
+ // Start returns (nil, err) after stopping itself, so a spec that defers
+ // c.Stop before asserting the error would otherwise nil-deref.
+ if c == nil {
+ return
+ }
+ for _, p := range append(append(append([]*Process{}, c.workers...), c.agentWorkers...), c.frontends...) {
+ p.terminate()
+ }
+ if c.baseDir != "" {
+ _ = os.RemoveAll(c.baseDir)
+ }
+}
+
+// DumpLogs writes every process log to stdout. Call from an AfterEach guarded by
+// CurrentSpecReport().Failed().
+func (c *Cluster) DumpLogs() {
+ for _, p := range append(append(append([]*Process{}, c.frontends...), c.workers...), c.agentWorkers...) {
+ if p == nil {
+ continue
+ }
+ data, err := os.ReadFile(p.LogPath)
+ if err != nil {
+ fmt.Printf("=== %s: log unreadable: %v\n", p.Name, err)
+ continue
+ }
+ fmt.Printf("=== %s (%s) ===\n%s\n", p.Name, p.LogPath, string(data))
+ }
+}
+
+func waitReady(p *Process, url string) error {
+ deadline := time.Now().Add(readinessTimeout)
+ client := httpclient.NewWithTimeout(2 * time.Second)
+ var last error
+ for time.Now().Before(deadline) {
+ select {
+ case <-p.exited:
+ if p.waitErr == nil {
+ return fmt.Errorf("process exited cleanly before becoming ready")
+ }
+ return fmt.Errorf("process exited before becoming ready: %w", p.waitErr)
+ default:
+ }
+ resp, err := client.Get(url)
+ if err == nil {
+ _ = resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ return nil
+ }
+ last = fmt.Errorf("status %d", resp.StatusCode)
+ } else {
+ last = err
+ }
+ time.Sleep(readinessPoll)
+ }
+ return fmt.Errorf("not ready within %s: %w", readinessTimeout, last)
+}
+
+func copyExecutable(src, dst string) error {
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", src, err)
+ }
+ if err := os.WriteFile(dst, data, 0o755); err != nil {
+ return fmt.Errorf("writing %s: %w", dst, err)
+ }
+ return nil
+}
diff --git a/tests/e2e/distributed/cluster/cluster_suite_test.go b/tests/e2e/distributed/cluster/cluster_suite_test.go
new file mode 100644
index 000000000000..69f785ba4cad
--- /dev/null
+++ b/tests/e2e/distributed/cluster/cluster_suite_test.go
@@ -0,0 +1,13 @@
+package cluster_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestCluster(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Cluster Harness Suite")
+}
diff --git a/tests/e2e/distributed/cluster/cluster_test.go b/tests/e2e/distributed/cluster/cluster_test.go
new file mode 100644
index 000000000000..2f63d0a28e76
--- /dev/null
+++ b/tests/e2e/distributed/cluster/cluster_test.go
@@ -0,0 +1,87 @@
+package cluster_test
+
+import (
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/httpclient"
+ "github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Cluster options", Label("Distributed"), func() {
+ It("rejects a binary path that does not exist", func() {
+ _, err := cluster.Start(cluster.Options{
+ Binary: filepath.Join(os.TempDir(), "definitely-not-local-ai"),
+ PGDSN: "postgres://test:test@127.0.0.1:5432/x?sslmode=disable",
+ NatsURL: "nats://127.0.0.1:4222",
+ LogDir: GinkgoT().TempDir(),
+ Frontends: 1,
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("local-ai binary"))
+ })
+
+ It("rejects a cluster with no frontends", func() {
+ _, err := cluster.Start(cluster.Options{
+ Binary: "/bin/true",
+ PGDSN: "postgres://test:test@127.0.0.1:5432/x?sslmode=disable",
+ NatsURL: "nats://127.0.0.1:4222",
+ LogDir: GinkgoT().TempDir(),
+ Frontends: 0,
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("at least one frontend"))
+ })
+})
+
+// The HTTP flow inside AdminSession and GetJSON cannot run here: it needs a
+// built local-ai plus real Postgres and NATS, which arrive with the failover
+// suites. These specs cover the argument validation that would otherwise panic
+// on an out-of-range slice index inside a helper every later spec calls.
+var _ = Describe("Admin session", Label("Distributed"), func() {
+ It("reports a clear error when the frontend index is out of range", func() {
+ c := cluster.ForTestingEmpty()
+ _, err := c.AdminSession(3)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("frontend 3"))
+ })
+
+ It("reports a clear error when GetJSON names a frontend that does not exist", func() {
+ c := cluster.ForTestingEmpty()
+ err := c.GetJSON(httpclient.NewWithTimeout(time.Second), 1, "/api/nodes", &struct{}{})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("frontend 1"))
+ })
+})
+
+// Like the admin specs above, these cover argument validation only. Killing,
+// stopping and restarting a real replica needs a built local-ai plus Postgres
+// and NATS, so those paths stay unexecuted until the failover suites land.
+var _ = Describe("Failure primitives", Label("Distributed"), func() {
+ It("rejects an out-of-range frontend index rather than panicking", func() {
+ c := cluster.ForTestingEmpty()
+ Expect(c.KillFrontend(0)).To(MatchError(ContainSubstring("frontend 0 out of range")))
+ Expect(c.StopFrontendGracefully(2)).To(MatchError(ContainSubstring("frontend 2 out of range")))
+ Expect(c.KillWorker(1)).To(MatchError(ContainSubstring("worker 1 out of range")))
+ })
+
+ It("rejects a restart of a frontend index that does not exist", func() {
+ Expect(cluster.ForTestingEmpty().RestartFrontend(0)).
+ To(MatchError(ContainSubstring("frontend 0 out of range")))
+ })
+
+ It("rejects a negative index without treating it as an offset from the end", func() {
+ c := cluster.ForTestingEmpty()
+ Expect(c.KillFrontend(-1)).To(MatchError(ContainSubstring("frontend -1 out of range")))
+ Expect(c.KillWorker(-1)).To(MatchError(ContainSubstring("worker -1 out of range")))
+ Expect(c.FrontendAlive(-1)).To(BeFalse())
+ })
+
+ It("reports a frontend that was never started as not alive", func() {
+ Expect(cluster.ForTestingEmpty().FrontendAlive(0)).To(BeFalse())
+ })
+})
diff --git a/tests/e2e/distributed/cluster/failure.go b/tests/e2e/distributed/cluster/failure.go
new file mode 100644
index 000000000000..fe808401cd56
--- /dev/null
+++ b/tests/e2e/distributed/cluster/failure.go
@@ -0,0 +1,195 @@
+package cluster
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// KillFrontend SIGKILLs frontend i. This is the "replica died" case: no drain,
+// no graceful deregistration, sockets drop without a FIN from the application.
+//
+// The signal is delivered but not waited on, because a spec that asserts on the
+// cluster's reaction wants to observe the window between the death and the
+// survivors noticing it. Poll FrontendAlive with Eventually to join the exit.
+func (c *Cluster) KillFrontend(i int) error {
+ if err := c.checkFrontendIndex(i); err != nil {
+ return err
+ }
+ return signalProcess(c.frontends[i], syscall.SIGKILL)
+}
+
+// StopFrontendGracefully SIGTERMs frontend i. This is the rolling-update case:
+// the process gets a chance to drain and deregister. Like KillFrontend it does
+// not wait; the point of the distinction between the two is what the process
+// does with the time between the signal and its exit.
+func (c *Cluster) StopFrontendGracefully(i int) error {
+ if err := c.checkFrontendIndex(i); err != nil {
+ return err
+ }
+ return signalProcess(c.frontends[i], syscall.SIGTERM)
+}
+
+// KillWorker SIGKILLs worker i.
+func (c *Cluster) KillWorker(i int) error {
+ if err := c.checkWorkerIndex(i); err != nil {
+ return err
+ }
+ return signalProcess(c.workers[i], syscall.SIGKILL)
+}
+
+// RestartFrontend brings frontend i back on its original port with an empty
+// data directory, modelling a replaced pod rather than a resumed one.
+//
+// The port is pinned rather than reallocated: workers read LOCALAI_REGISTER_TO
+// once at boot and never re-resolve it, so a replica that returns on a new port
+// is unreachable by exactly the workers that registered with it, and the
+// failover the spec means to observe never happens. Rebinding is safe because
+// the previous listener is fully closed before the new process starts (see the
+// terminate below) and Go's listeners set SO_REUSEADDR, so a lingering
+// TIME_WAIT on an accepted connection does not block the bind.
+//
+// The data directory is wiped so the replica must rehydrate node, session and
+// job state from the shared Postgres and NATS. Keeping it would model a pod
+// with a persistent volume and would hide the very class of bug these tests
+// exist to find. This is only safe because startFrontend pins
+// LOCALAI_AUTH_HMAC_SECRET: the secret otherwise lives at
+// {DataPath}/.hmac_secret, and wiping it would make every session minted before
+// the restart hash to a row the restarted replica cannot find, turning a
+// failover assertion into an unexplained 401.
+//
+// The wipe also destroys state that nothing can rebuild. This harness sets no
+// LOCALAI_STORAGE_URL, so the distributed object store is a directory under
+// {DataPath} (core/application/distributed.go:146), and quantization and
+// fine-tune jobs write their outputs to {DataPath}/quantization and
+// {DataPath}/fine-tune (core/services/{quantization,finetune}/service.go:95);
+// agent state, router-corpus, the voiceprofile store and {DataPath}/traces go
+// the same way. Postgres keeps the job row, the artifact it points at is gone.
+// So a spec that finishes a quantization or fine-tune on a replica, restarts
+// it, and then asserts the artifact is retrievable fails for a storage reason
+// dressed up as a failover one. No spec does that today; this note is here so
+// the first one that tries does not spend a day on it.
+//
+// After StopFrontendGracefully, wait for the process to actually go before
+// restarting:
+//
+// Eventually(func() bool { return c.FrontendAlive(i) }, "20s", "500ms").
+// Should(BeFalse())
+//
+// FrontendAlive takes an index, so it has to be wrapped in a closure; handing
+// Gomega the method value directly fails immediately: Eventually reports that
+// the function it was given takes one argument and none were provided, and
+// points at Eventually().WithArguments(). Restart
+// terminates whatever is still running with SIGKILL, so restarting straight
+// after a SIGTERM cuts the drain short and quietly turns the rolling-update
+// case into the crash case, which is the opposite of what pairing those two
+// calls is meant to express.
+func (c *Cluster) RestartFrontend(i int) error {
+ if err := c.checkFrontendIndex(i); err != nil {
+ return err
+ }
+ old := c.frontends[i]
+ if old == nil {
+ return fmt.Errorf("frontend %d was never started, nothing to restart", i)
+ }
+ // frontendDataDir is relative when baseDir is empty, and this deletes it:
+ // a Cluster assembled by a future test helper without a work dir would have
+ // RemoveAll walking "frontend-N/data" under the package source directory.
+ if c.baseDir == "" {
+ return fmt.Errorf("refusing to wipe the data dir of frontend %d: cluster has no work dir", i)
+ }
+ // The old process may still be running (a restart with no preceding kill) or
+ // already dead but unreaped. terminate is idempotent, bounds its wait, and
+ // releases the log handle the replacement is about to reopen; without it the
+ // replacement races the old listener for the port and leaks a file
+ // descriptor per restart.
+ old.terminate()
+ if err := os.RemoveAll(c.frontendDataDir(i)); err != nil {
+ return fmt.Errorf("wiping data dir of frontend %d: %w", i, err)
+ }
+
+ p, err := c.startFrontend(i, old.Port)
+ if err != nil {
+ return fmt.Errorf("restarting frontend %d: %w", i, err)
+ }
+ c.frontends[i] = p
+ return nil
+}
+
+// FrontendAlive reports whether frontend i's process is still running.
+func (c *Cluster) FrontendAlive(i int) bool {
+ if i < 0 || i >= len(c.frontends) {
+ return false
+ }
+ return c.frontends[i].alive()
+}
+
+// alive reports whether the process is still running.
+//
+// The exited check is cheap hygiene, not a fix for the zombie window. The
+// reaper closes exited only after Cmd.Wait returns, and Wait marks the
+// os.Process done before it returns (runtime/os pidfd path), so by the time
+// exited is closed signal 0 already errors: this branch cannot fire earlier
+// than the one it precedes. The window that stays open is the other one,
+// between the child exiting and waitid collecting it: there the child is a
+// zombie, signal 0 to a zombie succeeds, and alive reports true for a process
+// that is already dead. There is no local fix; the caller's is to poll rather
+// than assert once, wrapping the index-taking FrontendAlive in a closure:
+//
+// Eventually(func() bool { return c.FrontendAlive(i) }, "20s", "500ms").
+// Should(BeFalse())
+func (p *Process) alive() bool {
+ if p == nil || p.Cmd == nil || p.Cmd.Process == nil {
+ return false
+ }
+ select {
+ case <-p.exited:
+ return false
+ default:
+ }
+ // Signal 0 tests for existence without delivering anything.
+ return p.Cmd.Process.Signal(syscall.Signal(0)) == nil
+}
+
+func signalProcess(p *Process, sig syscall.Signal) error {
+ if p == nil || p.Cmd == nil || p.Cmd.Process == nil {
+ return fmt.Errorf("process is not running")
+ }
+ if err := p.Cmd.Process.Signal(sig); err != nil {
+ return fmt.Errorf("signalling %s with %v: %w", p.Name, sig, err)
+ }
+ return nil
+}
+
+// checkFrontendIndex keeps the out-of-range wording identical across every
+// primitive, so a failing spec reads the same whichever one tripped.
+func (c *Cluster) checkFrontendIndex(i int) error {
+ if i < 0 || i >= len(c.frontends) {
+ return fmt.Errorf("frontend %d out of range (cluster has %d)", i, len(c.frontends))
+ }
+ return nil
+}
+
+// checkWorkerIndex is checkFrontendIndex for workers, and exists for the same
+// reason: one wording, whichever primitive tripped.
+func (c *Cluster) checkWorkerIndex(i int) error {
+ if i < 0 || i >= len(c.workers) {
+ return fmt.Errorf("worker %d out of range (cluster has %d)", i, len(c.workers))
+ }
+ return nil
+}
+
+func frontendName(i int) string {
+ return fmt.Sprintf("frontend-%d", i)
+}
+
+func (c *Cluster) frontendDir(i int) string {
+ return filepath.Join(c.baseDir, frontendName(i))
+}
+
+// frontendDataDir is LOCALAI_DATA_PATH for frontend i. RestartFrontend wipes it,
+// so it must be the exact path startFrontend hands the child.
+func (c *Cluster) frontendDataDir(i int) string {
+ return filepath.Join(c.frontendDir(i), "data")
+}
diff --git a/tests/e2e/distributed/cluster_baseline_test.go b/tests/e2e/distributed/cluster_baseline_test.go
new file mode 100644
index 000000000000..1b2aaae2be04
--- /dev/null
+++ b/tests/e2e/distributed/cluster_baseline_test.go
@@ -0,0 +1,386 @@
+package distributed_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/httpclient"
+ "github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ // nodeRosterTimeout bounds the wait for a worker to appear healthy in
+ // /api/nodes. Registration is an HTTP call the worker retries, followed by a
+ // heartbeat that has to land before the frontend calls the node healthy, so
+ // the budget covers several retry intervals rather than a single round trip.
+ nodeRosterTimeout = "90s"
+ nodeRosterPoll = "1s"
+ // authProbeTimeout bounds the single unauthenticated request that checks the
+ // admin gate is actually closed. One round trip against a ready local
+ // process; anything slower is a defect, not slowness.
+ authProbeTimeout = 30 * time.Second
+)
+
+// node is the subset of the /api/nodes payload these specs assert on. ID is the
+// registration identity the worker minted, which is what distinguishes "the
+// same node row seen from a second replica" from "a second registration that
+// happens to share a name".
+type node struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ // Address and HTTPAddress are what a PRE-TUNNEL worker advertised. A worker
+ // running this release sends neither, which is the fact the tunnel specs
+ // assert on: with nothing advertised there is no address a frontend could
+ // have dialled instead of the tunnel.
+ Address string `json:"address"`
+ HTTPAddress string `json:"http_address"`
+ // LastHeartbeat is what separates "the worker is gone" from "the worker is
+ // here and this deployment cannot reach it". A spec asserting the second
+ // has to show the first is false, and the heartbeat is the only evidence
+ // of that in this payload.
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ // keys is what the payload actually carried, which a decoded struct cannot
+ // tell you. Both fields above are the zero value when a worker advertises
+ // nothing AND when the key was renamed or dropped, and the whole point of
+ // the change these specs cover was removing the advertisement, so a rename
+ // would leave "it advertises nothing" passing for a payload that no longer
+ // says anything either way.
+ keys map[string]json.RawMessage `json:"-"`
+}
+
+// String is what a failing assertion prints for a node.
+//
+// Without it %+v renders keys, whose values are json.RawMessage, as slices of
+// byte VALUES: one node becomes several hundred numbers and a roster of two
+// buries the assertion that failed. The key set is still what advertisementOf
+// reads; it is just not something a human ever needs to see.
+func (n node) String() string {
+ return fmt.Sprintf("{name:%s status:%s id:%s lastHeartbeat:%s advertised:%q/%q}",
+ n.Name, n.Status, n.ID, n.LastHeartbeat.Format(time.RFC3339), n.Address, n.HTTPAddress)
+}
+
+// UnmarshalJSON decodes the fields above and keeps the raw key set beside them.
+func (n *node) UnmarshalJSON(data []byte) error {
+ // A distinct type, or this method calls itself.
+ type decoded node
+ var plain decoded
+ if err := json.Unmarshal(data, &plain); err != nil {
+ return err
+ }
+ *n = node(plain)
+ return json.Unmarshal(data, &n.keys)
+}
+
+// requireBinaries reports whether a missing binary must fail the spec instead of
+// skipping it. It defaults to ON under CI.
+//
+// Skipping is the right courtesy locally: someone who has not run `make build`
+// should get a clear note, not a wall of red. In CI it is the opposite. The
+// whole Cluster label partition is these two specs, so if the workflow's build
+// step breaks or moves its output, a skip would leave the job reporting
+// "0 Passed | 2 Skipped" and exiting 0. Ginkgo exits 0 on skips, so that job
+// goes green having never started a cluster, which is precisely the silent pass
+// this suite exists to make impossible.
+//
+// Hence the polarity: the safe behaviour is the default, keyed off CI (GitHub
+// Actions always sets it), and LOCALAI_E2E_REQUIRE_BINARIES exists to be forced
+// OFF rather than to be remembered ON. A future workflow author cannot reach
+// the green-on-nothing state by forgetting a line, only by writing one that
+// explicitly asks for it. A local developer sees no change: CI is unset in an
+// ordinary shell, so a missing binary still skips.
+func requireBinaries() bool {
+ value := strings.TrimSpace(os.Getenv("LOCALAI_E2E_REQUIRE_BINARIES"))
+ if value == "" {
+ return os.Getenv("CI") != ""
+ }
+ // ParseBool rejects these, and the fallback below reads anything it rejects
+ // as ON. Someone writing "off" plainly means off, and silently inverting
+ // them would be a worse trap than the one this flag removes.
+ switch strings.ToLower(value) {
+ case "off", "no", "n", "disabled":
+ return false
+ }
+ if parsed, err := strconv.ParseBool(value); err == nil {
+ return parsed
+ }
+ // Set to something meaningless means someone meant to turn this on. Reading
+ // it as false would quietly restore the silent skip the flag guards against.
+ return true
+}
+
+// missingBinary skips or fails, naming the path and how to produce it.
+func missingBinary(what, path, remedy string) {
+ GinkgoHelper()
+ message := fmt.Sprintf("%s not found at %s; %s", what, path, remedy)
+ if requireBinaries() {
+ Fail(message + " (binaries are required here, either under CI or via " +
+ "LOCALAI_E2E_REQUIRE_BINARIES, so this fails rather than skips: a skipped " +
+ "cluster spec is indistinguishable from a passing one)")
+ }
+ Skip(message)
+}
+
+// localAIBinary resolves the built binary.
+func localAIBinary() string {
+ GinkgoHelper()
+ path := os.Getenv("LOCALAI_E2E_BINARY")
+ if path == "" {
+ wd, err := os.Getwd()
+ Expect(err).ToNot(HaveOccurred())
+ path = filepath.Join(wd, "..", "..", "..", "local-ai")
+ }
+ if _, err := os.Stat(path); err != nil {
+ missingBinary("local-ai binary", path, "run `make build` or set LOCALAI_E2E_BINARY")
+ }
+ return path
+}
+
+func mockBackendBinary() string {
+ GinkgoHelper()
+ wd, err := os.Getwd()
+ Expect(err).ToNot(HaveOccurred())
+ path := filepath.Join(wd, "..", "mock-backend", "mock-backend")
+ if _, err := os.Stat(path); err != nil {
+ missingBinary("mock-backend", path, "run `make build-mock-backend`")
+ }
+ return path
+}
+
+// startCluster brings up a cluster against a freshly provisioned database and
+// registers cleanup, including a log dump on failure.
+//
+// customise runs against the assembled Options immediately before Start, for
+// the one spec that needs a non-default topology. It is variadic so every
+// existing caller keeps the plain two-argument form and the default shape.
+func startCluster(frontends, workers int, customise ...func(*cluster.Options)) *cluster.Cluster {
+ GinkgoHelper()
+ c, _ := startClusterOnFreshDB(frontends, workers, customise...)
+ return c
+}
+
+// startClusterOnFreshDB is startCluster plus the DSN of the database it was
+// given, for a spec that has to read a table no endpoint exposes.
+func startClusterOnFreshDB(frontends, workers int, customise ...func(*cluster.Options)) (*cluster.Cluster, string) {
+ GinkgoHelper()
+
+ // Resolved before SetupInfra so a missing binary skips without having paid
+ // for a database that the skip would then leave to DeferCleanup.
+ binary := localAIBinary()
+ mockBackend := mockBackendBinary()
+
+ infra := SetupInfra("cluster")
+
+ // The log directory must be predictable so CI can upload it as an artifact.
+ // GinkgoT().TempDir() lands under TMPDIR, which on a GitHub runner is not
+ // /tmp, so an artifact glob would silently match nothing.
+ logDir := os.Getenv("LOCALAI_E2E_LOG_DIR")
+ if logDir == "" {
+ logDir = GinkgoT().TempDir()
+ } else {
+ logDir = filepath.Join(logDir, sanitizeDBName(CurrentSpecReport().LeafNodeText))
+ Expect(os.MkdirAll(logDir, 0o755)).To(Succeed())
+ }
+
+ options := cluster.Options{
+ Binary: binary,
+ MockBackend: mockBackend,
+ PGDSN: infra.PGURL,
+ NatsURL: infra.NatsURL,
+ LogDir: logDir,
+ Frontends: frontends,
+ Workers: workers,
+ }
+ for _, apply := range customise {
+ apply(&options)
+ }
+
+ c, err := cluster.Start(options)
+ Expect(err).ToNot(HaveOccurred())
+
+ DeferCleanup(func() {
+ if CurrentSpecReport().Failed() {
+ c.DumpLogs()
+ }
+ c.Stop()
+ })
+ return c, infra.PGURL
+}
+
+// rosterProbe polls one frontend's node roster.
+//
+// It keeps the last error and the last roster it saw so a failing Eventually can
+// name the cause. Returning a bare nil on error makes a 401 at the second
+// replica, a JSON decode failure and "the worker never registered" all present
+// identically as an empty list, which is the least useful thing a failover
+// suite can say when it goes red.
+type rosterProbe struct {
+ cluster *cluster.Cluster
+ client *http.Client
+ frontend int
+
+ lastErr error
+ lastSeen []node
+}
+
+func newRosterProbe(c *cluster.Cluster, client *http.Client, frontend int) *rosterProbe {
+ return &rosterProbe{cluster: c, client: client, frontend: frontend}
+}
+
+// healthyNames returns nil on any error so Eventually keeps retrying: the roster
+// is unreachable for the first moments of a replica's life, and failing hard
+// there would only re-report a startup race.
+func (p *rosterProbe) healthyNames() []string {
+ var roster []node
+ if err := p.cluster.GetJSON(p.client, p.frontend, "/api/nodes", &roster); err != nil {
+ p.lastErr = err
+ return nil
+ }
+ p.lastErr = nil
+ p.lastSeen = roster
+ names := []string{}
+ for _, n := range roster {
+ if n.Status == "healthy" {
+ names = append(names, n.Name)
+ }
+ }
+ return names
+}
+
+// idOf returns the registration ID the roster last reported for a node name.
+func (p *rosterProbe) idOf(name string) string {
+ for _, n := range p.lastSeen {
+ if n.Name == name {
+ return n.ID
+ }
+ }
+ return ""
+}
+
+// advertisementOf returns whatever endpoints the roster last reported a node
+// advertising, joined for a failure message, and whether the payload carried
+// both advertisement keys at all.
+//
+// The second result is the assertion, not a detail. Removing the advertisement
+// is what the change under test did, so "the node advertises nothing" and "the
+// keys that would have carried it are gone from the payload" are the two
+// outcomes a spec has to keep apart: the first is the feature working, the
+// second is the spec having lost its subject and reporting the feature working
+// for any node at all, including one that advertises plenty.
+func (p *rosterProbe) advertisementOf(name string) (string, bool) {
+ for _, n := range p.lastSeen {
+ if n.Name != name {
+ continue
+ }
+ _, hasAddress := n.keys["address"]
+ _, hasHTTP := n.keys["http_address"]
+ return strings.TrimSpace(strings.Join([]string{n.Address, n.HTTPAddress}, " ")), hasAddress && hasHTTP
+ }
+ return "", false
+}
+
+// heartbeatOf is the last heartbeat the roster reported for a node, refreshed
+// on every call.
+//
+// A zero time is returned for a node the roster does not carry, which no
+// freshness assertion can accept: the spec that reads this is proving the
+// worker is still alive, and a missing node must not read as a recent
+// heartbeat.
+func (p *rosterProbe) heartbeatOf(name string) time.Time {
+ var roster []node
+ if err := p.cluster.GetJSON(p.client, p.frontend, "/api/nodes", &roster); err != nil {
+ p.lastErr = err
+ return time.Time{}
+ }
+ p.lastErr = nil
+ p.lastSeen = roster
+ for _, n := range roster {
+ if n.Name == name {
+ return n.LastHeartbeat
+ }
+ }
+ return time.Time{}
+}
+
+// describe is handed to Should as the failure message. Gomega calls a
+// func() string description lazily, so this runs only on failure and reports
+// whichever of the two distinct causes actually occurred.
+func (p *rosterProbe) describe() string {
+ if p.lastErr != nil {
+ return fmt.Sprintf("frontend %d: the last GET /api/nodes failed: %v", p.frontend, p.lastErr)
+ }
+ return fmt.Sprintf("frontend %d: GET /api/nodes succeeded but the roster held %d node(s): %+v",
+ p.frontend, len(p.lastSeen), p.lastSeen)
+}
+
+var _ = Describe("Cluster baseline", Label("Distributed"), Label("Cluster"), func() {
+ It("brings up a frontend and a worker, and the worker appears in the roster", func() {
+ c := startCluster(1, 1)
+
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ })
+
+ It("runs two frontends against one database and both see the same worker", func() {
+ c := startCluster(2, 1)
+
+ // Observe that frontend 1 really is gated before proving a session opens
+ // it. Without this, "the cookie minted at frontend 0 works here" is
+ // indistinguishable from "this endpoint needs no auth at all". The probe
+ // is free: it touches no auth route, so it spends nothing from the
+ // five-per-minute-per-IP budget those routes share.
+ anonymous := httpclient.NewWithTimeout(authProbeTimeout)
+ refused, err := anonymous.Get(c.FrontendURL(1) + "/api/nodes")
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _ = refused.Body.Close() }()
+ Expect(refused.StatusCode).To(Equal(http.StatusUnauthorized),
+ "an unauthenticated GET /api/nodes must be refused, otherwise this spec proves nothing about sessions")
+
+ // One session for the whole cluster, minted at frontend 0. Registering or
+ // logging in again per frontend would spend from the same five-per-minute
+ // budget, and every request here comes from 127.0.0.1. The single client
+ // is valid at both replicas: sessions live in the shared Postgres, the
+ // harness pins one HMAC secret so the row resolves anywhere, and Go's
+ // cookie jar keys by host without port.
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ // The worker is pointed at frontend 0 alone (the harness sets
+ // LOCALAI_REGISTER_TO to frontend 0), so read its identity there first.
+ at0 := newRosterProbe(c, client, 0)
+ Eventually(at0.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), at0.describe)
+ registeredID := at0.idOf(c.WorkerName(0))
+ Expect(registeredID).ToNot(BeEmpty(), "frontend 0 reported the worker without a registration ID")
+
+ // Then assert frontend 1 serves the same row, by id and not merely by name.
+ //
+ // Be precise about what this proves. It does NOT pin the topology:
+ // NodeRegistry.Register looks a node up by name and preserves the
+ // existing id (core/services/nodes/registry.go:522-527), and both
+ // replicas read one Postgres, so a harness that registered the worker
+ // with every frontend would yield identical ids here too. What it does
+ // catch is a frontend answering from its own registry or its own
+ // database rather than the shared one, which is a different regression
+ // and just as silent. The topology fact is not asserted anywhere; it is
+ // recorded next to LOCALAI_REGISTER_TO in cluster.go.
+ at1 := newRosterProbe(c, client, 1)
+ Eventually(at1.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), at1.describe)
+ Expect(at1.idOf(c.WorkerName(0))).To(Equal(registeredID),
+ "frontend 1 must resolve the same node row as frontend 0; a differing id means it is not reading the shared state")
+ })
+})
diff --git a/tests/e2e/distributed/cluster_control_test.go b/tests/e2e/distributed/cluster_control_test.go
new file mode 100644
index 000000000000..d724f6037d0b
--- /dev/null
+++ b/tests/e2e/distributed/cluster_control_test.go
@@ -0,0 +1,797 @@
+package distributed_test
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// End-to-end proof that phase 3's control plane works under real processes.
+//
+// Phase 3 moved fourteen control verbs off NATS and onto the tunnel the worker
+// already dials, made absence a database fact rather than a bus timeout, and
+// took the backend worker off the bus entirely. Every one of those changes is
+// proven by unit and integration specs only; these are the first that run the
+// binaries an operator runs and let the real transports fail.
+//
+// Read the LAST spec before trusting the others. Frontend and worker share a
+// host here, so almost anything that looks like it went over the tunnel could
+// have gone some other way. The last one takes the tunnel away and requires the
+// control plane to become unreachable, with the refusal naming the missing
+// route rather than claiming the worker is absent; without it the rest could
+// pass with the tunnel doing nothing.
+
+const (
+ // controlRPCTimeout bounds one admin control call that has to cross a
+ // relay and reach a worker. Generous because the install verb behind it
+ // copies an artifact and starts a process.
+ controlRPCTimeout = 3 * time.Minute
+
+ // installJobTimeout bounds one node-scoped backend install from accepted to
+ // terminal. The gated spec spends most of it deliberately blocked.
+ installJobTimeout = "150s"
+ installJobPoll = "250ms"
+
+ // gateHoldWindow is how long a spec requires an install to stay
+ // unfinished while its gallery fetch is blocked.
+ //
+ // It is not a tolerance. The worker is stopped inside an HTTP read that
+ // only this spec can complete, so a terminal reply arriving inside this
+ // window is a reply written before the work it reports on, which is the
+ // exact defect the envelope ordering exists to prevent.
+ gateHoldWindow = "3s"
+ gateHoldPoll = "250ms"
+
+ // departedTimeout bounds the wait for a worker whose tunnel is gone past
+ // the grace to stop being reported healthy. It has to clear the grace the
+ // spec sets plus one health-monitor tick (15s by default).
+ departedTimeout = "90s"
+ departedPoll = "1s"
+
+ // churnGrace is the reconnect grace the churn spec runs with: long enough
+ // that the whole scenario, including the worker's capped 30s reconnect
+ // backoff and the re-home after it, happens INSIDE the window. The claim
+ // under test is "nothing is reaped inside the grace", so the window has to
+ // be the thing that is generous, not the assertion.
+ churnGrace = 10 * time.Minute
+
+ // churnHoldWindow is how long the churn spec requires the fleet to survive
+ // with its tunnel genuinely gone. It starts only after the tunnel has been
+ // OBSERVED unowned, and it is past the health monitor's 15s tick, so at
+ // least one sweep runs against a worker nothing can reach and finds nothing
+ // to reap.
+ churnHoldWindow = "20s"
+ churnHoldPoll = "1s"
+
+ // churnDropTimeout bounds the wait for a killed replica's claim to stop
+ // reading as a live owner.
+ //
+ // It has to outlast cluster.InstanceLiveness (30s): the ownership query
+ // joins against instances the DATABASE still considers live, so for the
+ // half minute after a SIGKILL a dead replica is still reported as holding
+ // every tunnel it held. That window is the reason this spec waits for the
+ // drop instead of assuming the kill produced one, and it is what an earlier
+ // version of this spec got wrong: its hold window sat entirely inside the
+ // liveness window, so it never reached the branch it claims to test and
+ // stayed green with the reconnect grace set to a nanosecond.
+ churnDropTimeout = "90s"
+ churnDropPoll = "1s"
+
+ // probeBackend is the backend a control spec installs on a worker.
+ //
+ // A name nothing else knows, and that is the point: the frontends' own
+ // backends directory is empty and the worker's holds only mock-backend, so
+ // a node backend listing that names this one can only have come from the
+ // worker, and only after an install that reached it.
+ probeBackend = "relay-probe"
+
+ // probeGalleryName is the gallery the probe backend is served from.
+ probeGalleryName = "e2e-control"
+)
+
+// nodeModel is the subset of a /api/nodes/:id/models row these specs assert on.
+type nodeModel struct {
+ ModelName string `json:"model_name"`
+ State string `json:"state"`
+ Address string `json:"address"`
+}
+
+// nodeBackend is the subset of a /api/nodes/:id/backends row these specs assert
+// on. It is the worker's own answer to the backend.list control verb, relayed
+// back through whichever replica took the request.
+type nodeBackend struct {
+ Name string `json:"name"`
+}
+
+// installJob is the subset of galleryop.OpStatus these specs assert on.
+//
+// It is read from GET /backends/jobs/:uuid and not from the UI's
+// /api/backends/job/:uid, because only the former carries the per-node
+// breakdown, and the per-node breakdown is where a worker's progress line
+// lands. The UI endpoint drops it.
+type installJob struct {
+ Processed bool `json:"processed"`
+ Message string `json:"message"`
+ Error string `json:"error"`
+ Nodes []installJobNode `json:"nodes"`
+}
+
+type installJobNode struct {
+ NodeID string `json:"node_id"`
+ Status string `json:"status"`
+ Phase string `json:"phase"`
+ Error string `json:"error"`
+}
+
+// nodeEntry returns the per-node row for nodeID, or nil.
+func (j installJob) nodeEntry(nodeID string) *installJobNode {
+ for i := range j.Nodes {
+ if j.Nodes[i].NodeID == nodeID {
+ return &j.Nodes[i]
+ }
+ }
+ return nil
+}
+
+// controlSession is one admin session for a whole cluster, with a budget long
+// enough for a control RPC that crosses a relay.
+//
+// One per spec and never one per frontend, for the reason inferenceClient gives:
+// the auth routes share a five-per-minute-per-IP budget and every request here
+// comes from 127.0.0.1.
+func controlSession(c *cluster.Cluster) *http.Client {
+ GinkgoHelper()
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+ client.Timeout = controlRPCTimeout
+ return client
+}
+
+// nodeBackendNames lists the backends a worker reports installed, asked at one
+// specific frontend.
+//
+// The frontend answers this by issuing the backend.list control verb over that
+// worker's tunnel, relayed through the owner when this replica is not it, so
+// the returned names are the WORKER's and not this process's.
+func nodeBackendNames(c *cluster.Cluster, client *http.Client, frontend int, nodeID string) ([]string, error) {
+ var listed []nodeBackend
+ if err := c.GetJSON(client, frontend, "/api/nodes/"+nodeID+"/backends", &listed); err != nil {
+ return nil, err
+ }
+ names := []string{}
+ for _, b := range listed {
+ names = append(names, b.Name)
+ }
+ return names, nil
+}
+
+// nodeModelNames lists the models a frontend records as loaded on a worker.
+func nodeModelNames(c *cluster.Cluster, client *http.Client, frontend int, nodeID string) ([]string, error) {
+ var rows []nodeModel
+ if err := c.GetJSON(client, frontend, "/api/nodes/"+nodeID+"/models", &rows); err != nil {
+ return nil, err
+ }
+ names := []string{}
+ for _, m := range rows {
+ names = append(names, m.ModelName)
+ }
+ return names, nil
+}
+
+// gatedGallery serves one backend gallery index, and can hold the request open
+// until a spec lets it go.
+//
+// The gate is what makes the ordering assertion deterministic rather than
+// tolerant. A worker install that finishes in milliseconds gives a poller no
+// window to observe progress in, and a spec that "usually" sees a tick before
+// the reply is a spec that passes for timing reasons. Here the worker is
+// stopped inside the gallery fetch, which happens AFTER it emits its first
+// progress line and BEFORE it can produce any reply, so the two are separated
+// by something the spec controls instead of by luck.
+//
+// fetches counts what the worker actually asked for, so a spec can prove the
+// gate is on the path it thinks it is rather than assuming it.
+type gatedGallery struct {
+ server *httptest.Server
+ index string
+ open chan struct{}
+ once sync.Once
+ fetches atomic.Int64
+}
+
+// newGatedGallery starts a gallery serving index. When gated, the first and
+// every subsequent fetch blocks until release is called.
+func newGatedGallery(index string, gated bool) *gatedGallery {
+ GinkgoHelper()
+ g := &gatedGallery{index: index, open: make(chan struct{})}
+ if !gated {
+ g.release()
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
+ g.fetches.Add(1)
+ select {
+ case <-g.open:
+ case <-r.Context().Done():
+ // The worker gave up, or the spec ended. Answering nothing is
+ // right: writing a body here would let a spec that failed its own
+ // gate assertion still see a successful install.
+ return
+ }
+ w.Header().Set("Content-Type", "application/yaml")
+ _, _ = io.WriteString(w, g.index)
+ })
+ g.server = httptest.NewServer(mux)
+ DeferCleanup(func() {
+ // Released before close so a handler still parked on the gate returns
+ // instead of leaking until the test binary exits.
+ g.release()
+ g.server.Close()
+ })
+ return g
+}
+
+func (g *gatedGallery) release() { g.once.Do(func() { close(g.open) }) }
+
+// URL is the index URL a worker fetches.
+func (g *gatedGallery) URL() string { return g.server.URL + "/index.yaml" }
+
+// galleriesJSON is the backend_galleries override an install request carries.
+// It is a JSON string INSIDE the request body, which is the shape
+// InstallBackendOnNodeEndpoint binds.
+func (g *gatedGallery) galleriesJSON() string {
+ return fmt.Sprintf(`[{"name":%q,"url":%q}]`, probeGalleryName, g.URL())
+}
+
+// probeBackendSource lays out a directory a worker can install as a backend and
+// then RUN, and returns its path.
+//
+// It is a real backend by the two rules core/gallery enforces on a directory
+// URI: a run.sh, which is the validation gate, and an executable named after
+// the backend, which is what the worker's findBackend resolves and starts. The
+// executable is the mock backend, so the install ends with a live gRPC process
+// rather than with a process that dies and turns a relay spec into a spec about
+// a broken artifact.
+func probeBackendSource() string {
+ GinkgoHelper()
+ dir := filepath.Join(GinkgoT().TempDir(), probeBackend)
+ Expect(os.MkdirAll(dir, 0o755)).To(Succeed())
+
+ binary, err := os.ReadFile(mockBackendBinary())
+ Expect(err).ToNot(HaveOccurred())
+ Expect(os.WriteFile(filepath.Join(dir, probeBackend), binary, 0o755)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(dir, "run.sh"),
+ []byte("#!/bin/sh\nexec \"$(dirname \"$0\")/"+probeBackend+"\" \"$@\"\n"), 0o755)).To(Succeed())
+ return dir
+}
+
+// probeGalleryIndex is the one-entry gallery index that points at src.
+func probeGalleryIndex(src string) string {
+ return fmt.Sprintf("- name: %s\n uri: %s\n description: e2e control-plane probe\n", probeBackend, src)
+}
+
+// startNodeInstall posts a node-scoped backend install at one frontend and
+// returns the job id it was given.
+//
+// The install is asynchronous by design (202 plus a job id), so this is where
+// the request stops and the job polling below takes over.
+func startNodeInstall(c *cluster.Cluster, client *http.Client, frontend int, nodeID, backend, galleriesJSON string) string {
+ GinkgoHelper()
+ var accepted struct {
+ JobID string `json:"jobID"`
+ }
+ status, err := c.PostJSON(client, frontend, "/api/nodes/"+nodeID+"/backends/install", map[string]string{
+ "backend": backend,
+ "backend_galleries": galleriesJSON,
+ }, &accepted)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(status).To(Equal(http.StatusAccepted),
+ "frontend %d refused the install outright, so nothing was ever sent to the worker", frontend)
+ Expect(accepted.JobID).ToNot(BeEmpty())
+ return accepted.JobID
+}
+
+// jobProbe polls one install job at one frontend and keeps what it last saw, so
+// a failing Eventually can name it.
+type jobProbe struct {
+ cluster *cluster.Cluster
+ client *http.Client
+ frontend int
+ jobID string
+ nodeID string
+
+ lastErr error
+ last installJob
+}
+
+func newJobProbe(c *cluster.Cluster, client *http.Client, frontend int, jobID, nodeID string) *jobProbe {
+ return &jobProbe{cluster: c, client: client, frontend: frontend, jobID: jobID, nodeID: nodeID}
+}
+
+// read fetches the job once, keeping the error rather than raising it.
+func (p *jobProbe) read() installJob {
+ var job installJob
+ if err := p.cluster.GetJSON(p.client, p.frontend, "/backends/jobs/"+p.jobID, &job); err != nil {
+ p.lastErr = err
+ return installJob{}
+ }
+ p.lastErr = nil
+ p.last = job
+ return job
+}
+
+// nodePhase is the phase the worker last reported for this node, or "" when the
+// job carries no row for it yet.
+func (p *jobProbe) nodePhase() string {
+ job := p.read()
+ if entry := job.nodeEntry(p.nodeID); entry != nil {
+ return entry.Phase
+ }
+ return ""
+}
+
+// nodeStatus is the status the job last recorded for this node.
+func (p *jobProbe) nodeStatus() string {
+ job := p.read()
+ if entry := job.nodeEntry(p.nodeID); entry != nil {
+ return entry.Status
+ }
+ return ""
+}
+
+// processed reports whether the job has reached a terminal state.
+func (p *jobProbe) processed() bool { return p.read().Processed }
+
+// jobError is the job's error text, empty when it has none.
+func (p *jobProbe) jobError() string { return p.read().Error }
+
+// explain builds a lazy failure description.
+//
+// Gomega formats a (string, args...) description when the assertion is
+// CONSTRUCTED, which for an Eventually or a Consistently is before anything has
+// gone wrong: the job it quoted would be the one from before the wait. A
+// func() string is called only on failure. The same trap, and the same fix, as
+// rosterProbe.explain.
+func (p *jobProbe) explain(format string, args ...any) func() string {
+ return func() string {
+ return fmt.Sprintf(format, args...) + ": " + p.describe()
+ }
+}
+
+func (p *jobProbe) describe() string {
+ if p.lastErr != nil {
+ return fmt.Sprintf("frontend %d: the last read of job %s failed: %v", p.frontend, p.jobID, p.lastErr)
+ }
+ return fmt.Sprintf("frontend %d: job %s last read as %+v", p.frontend, p.jobID, p.last)
+}
+
+// withReconnectGrace pins how long a lost worker tunnel is read as reconnecting
+// rather than gone.
+func withReconnectGrace(d time.Duration) func(*cluster.Options) {
+ return func(o *cluster.Options) { o.ReconnectGrace = d }
+}
+
+// withAgentWorkers adds agent workers, which still speak NATS and hold no
+// tunnel, to a cluster.
+func withAgentWorkers(n int) func(*cluster.Options) {
+ return func(o *cluster.Options) { o.AgentWorkers = n }
+}
+
+var _ = Describe("Control plane over the worker tunnel", Label("Distributed"), Label("Cluster"), func() {
+
+ // Scenario 1, the headline. A backend worker that is connected to no bus at
+ // all registers, is scheduled onto, and serves inference.
+ //
+ // A wrong implementation leaves the worker up and inert, because nothing
+ // reaches it: before phase 3 every control verb travelled on NATS, so a
+ // worker with no NATS URL would register and heartbeat and never be given a
+ // backend or a model.
+ It("registers, schedules onto and serves a worker that is connected to no bus", func() {
+ c, dsn := startClusterOnFreshDB(1, 1, withMockModel("busless-model"))
+ client := inferenceClient(c)
+
+ // The environment of the RUNNING process, not the one the harness
+ // assembled. LOCALAI_REGISTER_TO is asserted alongside so an absent
+ // LOCALAI_NATS_URL is a fact about the worker rather than a read that
+ // returned nothing: a broken read would lose both.
+ environ, err := c.WorkerEnviron(0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(environ).To(ContainElement(HavePrefix("LOCALAI_REGISTER_TO=")),
+ "the worker's environment could not be read, so the absence below proves nothing")
+ for _, entry := range environ {
+ Expect(entry).ToNot(HavePrefix("LOCALAI_NATS_URL="),
+ "the worker was handed a bus URL, so this spec is not about a worker that has none")
+ }
+ // And the deployment it joined DOES have a bus, so "no NATS anywhere"
+ // is not what makes this pass.
+ Expect(c.NatsURL()).ToNot(BeEmpty())
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // Its tunnel is held here, and it advertises nothing. Both matter: the
+ // first says there is a route, the second says there is no other one.
+ owners := newTunnelOwners(openClusterDB(dsn))
+ Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(0), owners.describe)
+ advertised, carriedKeys := probe.advertisementOf(c.WorkerName(0))
+ Expect(carriedKeys).To(BeTrue(),
+ "the roster payload no longer carries the advertisement keys, so this spec cannot tell a worker that advertises nothing from one it cannot read")
+ Expect(advertised).To(BeEmpty(),
+ "the worker advertised %q, so a frontend could have reached it without the tunnel", advertised)
+
+ // The inference is what drives the whole control plane: the frontend
+ // installs the backend on the worker, stages the model artifact to it
+ // and loads the model, all over the tunnel and all with no bus on the
+ // worker's side.
+ expectMockedInference(client, c.FrontendURL(0), "busless-model",
+ "a worker with no bus connection must still be scheduled onto and serve inference")
+
+ // And the frontend recorded the model as loaded THERE, naming the
+ // process the worker started. An empty address would mean the install
+ // reply named none, which is refused now rather than substituted.
+ var rows []nodeModel
+ Expect(c.GetJSON(client, 0, "/api/nodes/"+nodeID+"/models", &rows)).To(Succeed())
+ Expect(rows).ToNot(BeEmpty(), "no model is recorded on the worker that just served the request")
+ Expect(rows[0].ModelName).To(Equal("busless-model"))
+ Expect(rows[0].State).To(Equal("loaded"))
+ Expect(rows[0].Address).ToNot(BeEmpty(),
+ "the row names no backend process on the worker, so nothing could address it again")
+ })
+
+ // Scenario 2, the relay, and scenario 3, the ordering, in one cluster.
+ //
+ // They are one spec because a relay spec needs something to ask a worker
+ // that only the worker can answer, and a fresh worker's backend list is
+ // empty: the install is what puts something there. Splitting them would
+ // have cost a second cluster to assert less.
+ //
+ // A wrong implementation answers the control RPC by reaching the worker
+ // from the replica that took it, which works on one host and nowhere else,
+ // or refuses it as a worker that is not connected. A wrong STREAM
+ // implementation writes the terminal reply before the progress it reports
+ // on, or drops the progress entirely; both pass every unit spec.
+ It("installs a backend, streams its progress in order, and lists it back, all through the replica that does not own the worker", func() {
+ c, dsn := startClusterOnFreshDB(2, 1)
+ client := controlSession(c)
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // Which replica owns the tunnel is READ through the production Owner
+ // query, not assumed. The harness sends the worker to frontend 0 by
+ // default, and a spec that wrote that down would keep passing after the
+ // default changed while quietly testing the owner path instead.
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+
+ nonOwner := 1 - owner
+ Expect(owners.ownerIndexOf(c, 2, nodeID)).ToNot(Equal(nonOwner),
+ "frontend %d owns the worker's tunnel, so a request to it would not be relayed and this spec would prove nothing", nonOwner)
+
+ // Nothing is installed on the worker yet, and the request below is the
+ // only thing that could change that.
+ Expect(nodeBackendNames(c, client, nonOwner, nodeID)).ToNot(ContainElement(probeBackend),
+ "the worker already has %q, so a listing that names it later says nothing about this install", probeBackend)
+
+ gallery := newGatedGallery(probeGalleryIndex(probeBackendSource()), true)
+ jobID := startNodeInstall(c, client, nonOwner, nodeID, probeBackend, gallery.galleriesJSON())
+ job := newJobProbe(c, client, nonOwner, jobID, nodeID)
+
+ // The worker has emitted its first progress line and is now stopped
+ // inside the gallery fetch. Seeing the phase here proves a progress
+ // envelope crossed the relay and was decoded, and it proves it BEFORE
+ // any reply could exist, because the worker cannot produce one until
+ // this spec releases the gate.
+ Eventually(job.nodePhase, installJobTimeout, installJobPoll).Should(Equal("resolving"), job.describe)
+ Eventually(gallery.fetches.Load, installJobTimeout, installJobPoll).Should(BeNumerically(">", 0),
+ "the worker never fetched the gated gallery, so the gate is not on the path this spec thinks it is")
+
+ // Held, not sampled. A reply arriving in this window is a reply written
+ // ahead of the work it reports on.
+ Consistently(job.processed, gateHoldWindow, gateHoldPoll).Should(BeFalse(),
+ job.explain("the install reported a terminal result while the worker was still blocked fetching its gallery"))
+
+ gallery.release()
+
+ Eventually(job.processed, installJobTimeout, installJobPoll).Should(BeTrue(), job.describe)
+ Expect(job.jobError()).To(BeEmpty(), "the relayed install failed: %s", job.describe())
+ Expect(job.nodeStatus()).To(Equal("success"), job.describe)
+
+ // Nothing follows the terminal reply. A worker that appended a late
+ // progress line after its reply would move this row back to
+ // "downloading"; the guard against that is ndjsonStream.done, and this
+ // is the only place it is exercised over a real stream.
+ Consistently(job.nodeStatus, gateHoldWindow, gateHoldPoll).Should(Equal("success"),
+ job.explain("the node's status moved after the install's terminal reply"))
+
+ // A SECOND control verb across the relay, and the one whose answer
+ // could only have come from the worker: the frontends' own backends
+ // directories are empty, and this backend was installed on the worker
+ // alone.
+ Eventually(func() ([]string, error) { return nodeBackendNames(c, client, nonOwner, nodeID) },
+ installJobTimeout, installJobPoll).Should(ContainElement(probeBackend))
+
+ // And the frontend that answered does not have it itself, so it cannot
+ // have been reporting its own installation as the worker's.
+ //
+ // Read off the filesystem and not from GET /backends: in distributed
+ // mode that endpoint reports the CLUSTER's backends, so it names this
+ // one whether the frontend has it or not, and an assertion on it fails
+ // for a reason that has nothing to do with what is being proven.
+ backendsDir, err := c.FrontendBackendsDir(nonOwner)
+ Expect(err).ToNot(HaveOccurred())
+ entries, err := os.ReadDir(backendsDir)
+ Expect(err).ToNot(HaveOccurred())
+ for _, e := range entries {
+ Expect(e.Name()).ToNot(Equal(probeBackend),
+ "frontend %d installed %q locally, so its answer about the worker may be about itself", nonOwner, probeBackend)
+ }
+
+ // THIS is what makes every request above a relayed one. It rules out
+ // the two ways they could have succeeded without a relay: a replica
+ // that took the tunnel for itself, and the tunnel moving to nonOwner
+ // mid-spec so that it served directly. Both leave the owner changed.
+ Expect(owners.ownerIndexOf(c, 2, nodeID)).To(Equal(owner),
+ "the tunnel is no longer held by frontend %d, so the requests to frontend %d were not necessarily relayed", owner, nonOwner)
+ })
+
+ // Scenario 4, absence under replica churn. The catastrophe control.
+ //
+ // A worker whose tunnel drops and returns INSIDE the grace must not be
+ // reaped. A wrong implementation reads a lost tunnel as a departed worker
+ // and evicts the models it is serving in the seconds before the re-home,
+ // which is the fleet-wide outage the four-valued presence answer exists to
+ // prevent.
+ It("reaps nothing when the replica holding a worker's tunnel dies and the worker re-homes inside the grace", func() {
+ var balancer *frontendBalancer
+ c, dsn := startClusterOnFreshDB(2, 1, withMockModel("churn-model"),
+ withBalancer(&balancer), withReconnectGrace(churnGrace))
+
+ client := inferenceClient(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+ survivor := 1 - owner
+
+ // A model actually loaded on the worker, or there is nothing to reap
+ // and the assertions below hold vacuously.
+ expectMockedInference(client, c.FrontendURL(owner), "churn-model",
+ "inference must work before the owner is killed, or nothing here is at risk")
+ Expect(nodeModelNames(c, client, survivor, nodeID)).To(ContainElement("churn-model"))
+
+ // The tunnel is taken away BEFORE the owner is killed, and held away
+ // until the assertions below have run.
+ //
+ // Without the block the worker re-homes onto the survivor within
+ // seconds, and a spec that asserted over those seconds would never
+ // reach the state it is about: for the first half minute after a
+ // SIGKILL the dead replica still reads as a live owner, so presence is
+ // "connected" and no rule about a departed worker has anything to act
+ // on. Blocking makes the outage last long enough for a departure to be
+ // recorded, which is the only way the grace is consulted at all.
+ balancer.blockTunnel.Store(true)
+ Expect(c.KillFrontend(owner)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(owner) }, "20s", "500ms").Should(BeFalse())
+
+ // Read at the SURVIVOR from here on: the probe above is bound to a dead
+ // process.
+ atSurvivor := newRosterProbe(c, client, survivor)
+
+ // Wait for the drop to be REAL rather than assume the kill produced
+ // one. Until this reads empty the ownership query still reports the
+ // dead replica, and everything below would be asserting about a worker
+ // the deployment believes is connected.
+ Eventually(func() string { return owners.ownerOf(nodeID) }, churnDropTimeout, churnDropPoll).
+ Should(BeEmpty(), owners.describe)
+
+ // THIS is the window the phase is about: the tunnel is gone, the
+ // departure is recorded, and the grace has not run out. Nothing may be
+ // reaped in it.
+ Consistently(func() []string {
+ names, err := nodeModelNames(c, client, survivor, nodeID)
+ if err != nil {
+ return nil
+ }
+ return names
+ }, churnHoldWindow, churnHoldPoll).Should(ContainElement("churn-model"),
+ "the model loaded on the worker was reaped while its tunnel was inside the reconnect grace")
+ Consistently(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, churnHoldWindow, churnHoldPoll).
+ ShouldNot(Equal("unhealthy"),
+ atSurvivor.explain("the worker was demoted while its tunnel was inside the reconnect grace"))
+
+ // And it comes back: with the block lifted the tunnel re-homes onto the
+ // survivor and the same model serves again. Without this the assertions
+ // above would be satisfied by a fleet that was simply never touched.
+ balancer.blockTunnel.Store(false)
+ Eventually(func() int { return owners.ownerIndexOf(c, 2, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(survivor), owners.describe)
+ Eventually(atSurvivor.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), atSurvivor.describe)
+ Expect(atSurvivor.idOf(c.WorkerName(0))).To(Equal(nodeID),
+ "the worker re-registered rather than re-homing, so this proves nothing about a tunnel surviving its replica")
+
+ eventuallyMockedInference(client, c.FrontendURL(survivor), "churn-model",
+ "the survivor must serve the re-homed worker")
+ })
+
+ // Scenario 5, the wedge task 6 fixed, plus the agent-worker control.
+ //
+ // A worker with a FRESH heartbeat and a tunnel that is gone past the grace
+ // must stop being reported healthy. Before task 6 it stayed healthy
+ // forever, with every request for a model on it failing "no route", because
+ // every reaper keyed on the heartbeat and the heartbeat was fine.
+ //
+ // The agent worker in the same cluster is the control for the other
+ // direction. It still speaks NATS, holds no tunnel and never will, so a
+ // rule that read "no tunnel" as "gone" would take the whole agent fleet
+ // down with it.
+ It("stops reporting a heartbeating worker healthy once its tunnel is gone past the grace, and leaves agent workers alone", func() {
+ var balancer *frontendBalancer
+ c, dsn := startClusterOnFreshDB(2, 1, withBalancer(&balancer),
+ withAgentWorkers(1), withReconnectGrace(10*time.Second))
+
+ client := controlSession(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(And(ContainElement(c.WorkerName(0)), ContainElement(c.AgentWorkerName(0))), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+ survivor := 1 - owner
+
+ // Take the tunnel away permanently: block the dial, then kill the
+ // replica holding the live session. The worker keeps registering and
+ // heartbeating through the balancer, which still proxies everything
+ // except the tunnel connect.
+ balancer.blockTunnel.Store(true)
+ Expect(c.KillFrontend(owner)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(owner) }, "20s", "500ms").Should(BeFalse())
+
+ atSurvivor := newRosterProbe(c, client, survivor)
+
+ // The worker really is trying and really is being refused, so what
+ // makes it unreachable below is the tunnel and not the worker dying.
+ before := balancer.tunnelDials.Load()
+ Eventually(balancer.tunnelDials.Load, "90s", "500ms").Should(BeNumerically(">", before),
+ "the worker stopped dialling its tunnel, so blocking the dial is not what keeps it away")
+
+ // The verdict. Fresh heartbeat, no tunnel, past the grace.
+ Eventually(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, departedTimeout, departedPoll).
+ Should(Equal("unhealthy"), atSurvivor.describe)
+
+ // The heartbeat was NOT what demoted it. Without this the assertion
+ // above is satisfied by a worker that simply died, which says nothing
+ // about tunnel departure.
+ Expect(atSurvivor.heartbeatOf(c.WorkerName(0))).To(BeTemporally(">", time.Now().Add(-1*time.Minute)),
+ "the worker's heartbeat is stale, so it was demoted for being gone rather than for having no route")
+
+ // The agent worker, in the same cluster, under the same grace, on the
+ // same health monitor, is untouched. It holds no tunnel either.
+ Consistently(func() string { return atSurvivor.statusOf(c.AgentWorkerName(0)) }, "20s", "2s").
+ Should(Equal("healthy"),
+ atSurvivor.explain("an agent worker was demoted by a rule about tunnels, and agent workers never hold one"))
+
+ // And the demotion reverses when the route comes back, so it is a
+ // statement about the route rather than a one-way condemnation.
+ balancer.blockTunnel.Store(false)
+ Eventually(func() int { return owners.ownerIndexOf(c, 2, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(survivor), owners.describe)
+ Eventually(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, departedTimeout, departedPoll).
+ Should(Equal("healthy"), atSurvivor.describe)
+ })
+
+ // Scenario 6. THE NEGATIVE CONTROL FOR THE WHOLE SUITE.
+ //
+ // Frontend and worker share a host, so every address the control plane
+ // names is one the frontend could also have reached directly. If it did,
+ // every spec above would pass with the tunnel doing nothing. This one takes
+ // the tunnel away and requires the control plane to become unreachable,
+ // with the refusal naming the ROUTE, while registration, heartbeats and the
+ // roster stay exactly as they were.
+ //
+ // It cannot use LOCALAI_WORKER_TUNNEL=false: that is a fatal startup error
+ // after phase 2, and a worker that never started says nothing about a
+ // worker reachable by some other path.
+ It("cannot drive the control plane on a worker whose tunnel is refused, reaps nothing for it, and can as soon as it is not", func() {
+ var balancer *frontendBalancer
+ c, dsn := startClusterOnFreshDB(1, 1,
+ withBalancer(&balancer, func(b *frontendBalancer) { b.blockTunnel.Store(true) }))
+
+ client := controlSession(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // In every respect the worker the specs above used, except the tunnel,
+ // and both halves of that are asserted rather than assumed: it tried,
+ // and no replica holds it.
+ Eventually(balancer.tunnelDials.Load, "60s", "500ms").Should(BeNumerically(">", 0),
+ "the worker never dialled its tunnel, so blocking the dial is not what makes it unreachable below")
+ owners := newTunnelOwners(openClusterDB(dsn))
+ Consistently(func() string { return owners.ownerOf(nodeID) }, "5s", "500ms").Should(BeEmpty(),
+ "a replica holds this worker's tunnel, so the blocker is not blocking")
+
+ gallery := newGatedGallery(probeGalleryIndex(probeBackendSource()), false)
+ jobID := startNodeInstall(c, client, 0, nodeID, probeBackend, gallery.galleriesJSON())
+ job := newJobProbe(c, client, 0, jobID, nodeID)
+
+ Eventually(job.processed, installJobTimeout, installJobPoll).Should(BeTrue(), job.describe)
+
+ // It fails, and it fails for the ROUTING reason. A refusal for any
+ // other cause (a gallery it could not read, a node it thought absent, a
+ // backend that would not install) would satisfy "it failed" just as
+ // well and would leave every spec above unproven.
+ //
+ // One substring and not a disjunction: "no route" is what
+ // cluster.ErrNoRoute reads as, and nothing else on this path produces
+ // it. In particular it is NOT what an absent worker produces, and that
+ // distinction is the phase's whole absence contract: a worker this
+ // replica cannot reach must never be reported as one that has gone.
+ Expect(job.jobError()).To(ContainSubstring("no route"),
+ "the refusal does not name the missing route, so this spec cannot tell a worker with no tunnel from an ordinary install failure: %s", job.describe())
+
+ // And nothing was reaped for it. The worker never stopped
+ // heartbeating, so a control RPC that could not be delivered must not
+ // have cost it its row or its health.
+ _, listErr := nodeBackendNames(c, client, 0, nodeID)
+ Expect(listErr).To(HaveOccurred(),
+ "the frontend answered a backend listing for a worker it has no route to, so something other than the tunnel reaches it")
+ Consistently(probe.healthyNames, "10s", "1s").
+ Should(ContainElement(c.WorkerName(0)),
+ probe.explain("the worker was demoted or removed because a control RPC could not be routed to it"))
+
+ // The control's own control: put the tunnel back, change nothing else,
+ // and the SAME install must now succeed. Without this the failure above
+ // could be any of the ordinary reasons an e2e install fails.
+ balancer.blockTunnel.Store(false)
+ Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(0), owners.describe)
+
+ retryID := startNodeInstall(c, client, 0, nodeID, probeBackend, gallery.galleriesJSON())
+ retry := newJobProbe(c, client, 0, retryID, nodeID)
+ Eventually(retry.processed, installJobTimeout, installJobPoll).Should(BeTrue(), retry.describe)
+ Expect(retry.jobError()).To(BeEmpty(),
+ "the only thing that changed is the tunnel, so the failure above was the missing tunnel and nothing else: %s", retry.describe())
+ Eventually(func() ([]string, error) { return nodeBackendNames(c, client, 0, nodeID) },
+ installJobTimeout, installJobPoll).Should(ContainElement(probeBackend))
+ })
+})
diff --git a/tests/e2e/distributed/cluster_failover_test.go b/tests/e2e/distributed/cluster_failover_test.go
new file mode 100644
index 000000000000..d007a8ae6ac8
--- /dev/null
+++ b/tests/e2e/distributed/cluster_failover_test.go
@@ -0,0 +1,401 @@
+package distributed_test
+
+import (
+ "fmt"
+
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// The two sentinels below are returned by statusOf in place of a node status.
+// Neither can ever equal one of the nodes.Status* constants, which is the whole
+// point: every assertion in this file compares against a real status with
+// Equal, so an unreachable frontend or a vanished row fails the assertion and
+// names itself rather than quietly satisfying it.
+//
+// This is not hypothetical. The obvious way to write "the dead worker is gone"
+// is ShouldNot(ContainElement(name)) over a list of healthy names, and the
+// probe returns an empty list on any error, so an expired session, a 401 at the
+// second replica or a decode failure all satisfy that matcher. The spec would
+// go green having observed nothing at all.
+const (
+ statusUnreachable = ""
+ statusAbsent = ""
+)
+
+const (
+ // orphanEvictionWindow is how long a spec watches a roster to be sure the
+ // system had a real chance to evict a node and chose not to.
+ //
+ // It is sized from the only eviction path there is. Node liveness is
+ // heartbeat freshness: the health monitor wakes every HealthCheckInterval
+ // (15s) and marks any node whose last heartbeat is older than
+ // StaleNodeThreshold (60s) offline (core/services/nodes/health.go, defaults
+ // in core/config/distributed_config.go). Neither is settable from the CLI,
+ // so 60s + one 15s tick = 75s is the worst case and cannot be shortened.
+ //
+ // Measured rather than assumed: a worker whose registrar was killed goes
+ // offline at both surviving replicas at t=74.2s. A window shorter than that
+ // would be the classic false green, a Consistently that passes because
+ // nothing has had time to happen yet. 100s clears the measured latency by a
+ // third.
+ orphanEvictionWindow = "100s"
+ rosterPollInterval = "2s"
+
+ // workerDeathTimeout bounds the wait for a killed worker to be marked
+ // offline. Roughly twice the measured 74.3s, which absorbs a health tick
+ // landing just before the kill plus a slow CI runner.
+ workerDeathTimeout = "150s"
+
+ // settledStatusWindow is how long an observed status has to hold before the
+ // spec believes it. A killed worker does not go straight to offline: it
+ // flaps to unhealthy at ~8s and back to healthy at ~14s (see the note in
+ // the worker-death spec), so a status has to outlast that transient and two
+ // further 15s health ticks to count as the settled state.
+ settledStatusWindow = "45s"
+
+ // restartRehydrationTimeout bounds the wait for a cold-restarted replica to
+ // answer with the roster. The restart itself measured 1.0s; the budget is
+ // for a loaded CI runner, not for a slow code path.
+ restartRehydrationTimeout = "60s"
+
+ // frontendExitTimeout bounds the wait for a signalled frontend to be
+ // collected. SIGTERM measured 0.2s. It is polled rather than sampled
+ // because FrontendAlive reports true for the zombie window between the
+ // child exiting and the reaper calling waitid.
+ frontendExitTimeout = "30s"
+ frontendExitPoll = "200ms"
+)
+
+// statusOf refreshes the roster at the probe's frontend and returns the status
+// that frontend reports for name.
+//
+// It shares rosterProbe's lastErr/lastSeen so describe() still explains a
+// failure, but unlike healthyNames it never collapses an error into an empty
+// result: the caller is comparing against an exact status, so an error has to
+// be a value that no assertion can accept.
+func (p *rosterProbe) statusOf(name string) string {
+ var roster []node
+ if err := p.cluster.GetJSON(p.client, p.frontend, "/api/nodes", &roster); err != nil {
+ p.lastErr = err
+ return statusUnreachable
+ }
+ p.lastErr = nil
+ p.lastSeen = roster
+ for _, n := range roster {
+ if n.Name == name {
+ return n.Status
+ }
+ }
+ return statusAbsent
+}
+
+// explain builds a lazy failure description.
+//
+// Gomega formats a (string, args...) description as soon as the assertion is
+// constructed, which for an Eventually or a Consistently is before anything has
+// gone wrong; the roster it quoted would be the one from before the wait. A
+// func() string is called only on failure, so describe() reports the last
+// observation the assertion actually made.
+func (p *rosterProbe) explain(format string, args ...any) func() string {
+ return func() string {
+ return fmt.Sprintf(format, args...) + ": " + p.describe()
+ }
+}
+
+// explainStuckOffline is explain with one extra diagnosis attached.
+//
+// An assertion waiting for offline has a failure mode that looks like a harness
+// bug and is not one, so the message names it rather than leaving the reader to
+// find it. See proveHealthCheckingIsAlive and the comment on the dead-worker
+// spec for the mechanism.
+func (p *rosterProbe) explainStuckOffline(worker, format string, args ...any) func() string {
+ return func() string {
+ message := fmt.Sprintf(format, args...) + ": " + p.describe()
+ for _, n := range p.lastSeen {
+ if n.Name != worker || n.Status != nodes.StatusUnhealthy {
+ continue
+ }
+ message += "\n\nThe node is stuck at unhealthy, which is a LocalAI defect rather than " +
+ "a harness one: core/services/nodes/health.go:153-155 skips MarkOffline for a node " +
+ "already marked unhealthy, so a node whose unhealthy mark lands after its heartbeat " +
+ "has gone stale never reaches offline at all. Start there, not here."
+ }
+ return message
+ }
+}
+
+// proveHealthCheckingIsAlive kills a worker and waits for the roster to settle
+// it to offline.
+//
+// It is the terminating positive control for the two specs that assert a
+// healthy worker STAYS healthy. On their own those are pure negative
+// assertions: a cluster whose health checking had wedged entirely, say by
+// leaking the Postgres advisory lock the monitor takes
+// (core/services/nodes/health.go:112), would freeze the roster and satisfy them
+// while observing a corpse.
+//
+// WHAT IT ACTUALLY PROVES, which is less than it looks like. Killing a worker
+// afterwards and requiring the roster to react proves the monitor was alive at
+// the END of the preceding window. It does not observe the window itself. The
+// inference back across it holds only if a wedge would have been sticky, i.e.
+// still present when this helper ran.
+//
+// THE RESIDUAL GAP, and it is not hypothetical in the peer-replica-death spec.
+// Health checks are single-flighted across replicas by a session-scoped
+// pg_try_advisory_lock (advisorylock.TryWithLockCtx, non-blocking: a replica
+// that does not get the lock returns immediately and checks nothing, silently,
+// because checkAll discards the acquired flag). That spec SIGKILLs frontend 1,
+// which may have been holding the lock at the moment it died. Postgres releases
+// a session-level advisory lock only when it reaps the dead backend, so until
+// then frontend 0's ticks acquire nothing and no check runs. The roster freezes,
+// Consistently(healthy) passes BECAUSE NOTHING WAS CHECKING, and this helper
+// still succeeds afterwards once the session is reaped and the lock comes free.
+// That wedge is transient rather than permanent, which is exactly the shape the
+// backwards inference cannot see. Low probability, real, and bounded by how
+// fast Postgres reaps the dead backend, usually immediate on a local socket
+// close.
+//
+// So treat this as a floor and not a proof: it rules out a health monitor that
+// is permanently dead, which is the failure that would otherwise make the
+// preceding Consistently a statement about a stopped clock, and it does not rule
+// out a monitor that was idle for part of the window. Closing the gap needs a
+// positive observation from inside the window (a log or metric assertion that a
+// check ran), not a stronger assertion here.
+//
+// It costs a full detection cycle, which is why it is a shared helper: the
+// wall-clock price should be paid once per spec and explained once.
+func proveHealthCheckingIsAlive(c *cluster.Cluster, probe *rosterProbe, workerIndex int) {
+ GinkgoHelper()
+ worker := c.WorkerName(workerIndex)
+ Expect(c.KillWorker(workerIndex)).To(Succeed())
+ Eventually(probe.statusOf, workerDeathTimeout, rosterPollInterval).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusOffline),
+ probe.explainStuckOffline(worker,
+ "frontend %d never reacted to a killed worker, so health checking was not running during the window above and the assertion before this one proved nothing",
+ probe.frontend))
+}
+
+var _ = Describe("Cluster failover", Label("Distributed"), Label("Cluster"), func() {
+ It("keeps a healthy worker in the roster when a peer replica dies", func() {
+ // Two replicas, one worker. The worker registers and heartbeats with
+ // frontend 0 only (the harness default), so frontend 1 is a replica it
+ // has never spoken to.
+ c := startCluster(2, 1)
+ worker := c.WorkerName(0)
+
+ // One session for the whole cluster: register/login/token-login/password
+ // share a five-per-minute-per-IP budget at every frontend
+ // (core/http/routes/auth.go:190) and everything here comes from
+ // 127.0.0.1. The cookie is valid at both replicas because sessions live
+ // in the shared Postgres and the harness pins one HMAC secret.
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ survivor := newRosterProbe(c, client, 0)
+ Eventually(survivor.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(worker), survivor.describe)
+
+ // Kill the replica this worker never registered with. That choice is
+ // what makes the assertion below mean anything.
+ //
+ // Killing frontend 0 instead would sever the worker's only heartbeat
+ // path, because the heartbeat loop posts to the URL it was handed at
+ // boot and never re-resolves it; the worker is then genuinely orphaned
+ // and IS evicted, at a measured 74.2s. A spec written that way can only
+ // pass by watching for less time than the eviction takes.
+ Expect(c.WorkerRegistrar(0)).ToNot(Equal(1),
+ "this spec kills frontend 1 precisely because worker 0 does not depend on it")
+ Expect(c.KillFrontend(1)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(1) }, frontendExitTimeout, frontendExitPoll).
+ Should(BeFalse(), "frontend 1 did not die, so nothing below is a failover assertion")
+
+ // The survivor must keep answering, and must keep the worker healthy.
+ //
+ // A GET that returns 200 with a decodable roster is the "keeps serving"
+ // half; statusUnreachable would fail this matcher. The window outlasts
+ // the full 75s stale-plus-one-tick eviction path, so an implementation
+ // that reacted to a dead peer by sweeping its nodes, by resetting
+ // heartbeats, or by marking the whole roster stale would be caught
+ // whether it reacted immediately or on a health tick.
+ Consistently(survivor.statusOf, orphanEvictionWindow, rosterPollInterval).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusHealthy),
+ survivor.explain("killing a peer replica must not disturb a worker that never depended on it"))
+
+ // Everything above is a negative: nothing happened. Prove that the
+ // survivor was capable of making something happen the whole time.
+ proveHealthCheckingIsAlive(c, survivor, 0)
+ })
+
+ It("rediscovers the worker from shared state after a cold rolling restart", func() {
+ c := startCluster(2, 1)
+ worker := c.WorkerName(0)
+
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(worker), probe.describe)
+ registeredID := probe.idOf(worker)
+ Expect(registeredID).ToNot(BeEmpty(), "frontend 0 reported the worker without a registration ID")
+
+ // The rolling-update shape: drain, wait for the process to actually go,
+ // then bring the replacement up. Restarting without that wait would
+ // SIGKILL the replica mid-drain and silently turn this into the crash
+ // case.
+ Expect(c.StopFrontendGracefully(0)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(0) }, frontendExitTimeout, frontendExitPoll).
+ Should(BeFalse(), "frontend 0 ignored SIGTERM, so the restart below would be a SIGKILL mid-drain")
+
+ // RestartFrontend wipes the replica's data directory, so the process
+ // that comes back has no local memory of the cluster. Everything the
+ // assertions below observe has to come out of the shared Postgres.
+ Expect(c.RestartFrontend(0)).To(Succeed())
+
+ restarted := newRosterProbe(c, client, 0)
+ Eventually(restarted.statusOf, restartRehydrationTimeout, nodeRosterPoll).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusHealthy),
+ restarted.explain("a replica with an empty data directory must rehydrate the roster from shared state"))
+ Expect(restarted.idOf(worker)).To(Equal(registeredID),
+ "the restarted replica invented a new row for the worker instead of resolving the shared one")
+
+ // Rehydration alone is a weak claim: the row was written before the
+ // restart and would still read healthy for up to 75s even if the
+ // replacement never accepted another heartbeat. Holding it past that
+ // window is what proves the worker's heartbeats are landing again,
+ // which is the part a restart can plausibly break (a replacement on a
+ // different port, or one that rejects the node id it did not issue).
+ Consistently(restarted.statusOf, orphanEvictionWindow, rosterPollInterval).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusHealthy),
+ restarted.explain("the worker went stale after the restart, so its heartbeats are not reaching the replacement"))
+
+ // Same hole as the peer-death spec, and it is worse here: a cold
+ // restart is exactly the event that could leave a replacement unable to
+ // run health checks at all, and a frozen roster reads identically to a
+ // healthy one. This is the assertion that tells the two apart.
+ proveHealthCheckingIsAlive(c, restarted, 0)
+ })
+
+ It("settles a dead worker to offline and both replicas report it offline", func() {
+ c := startCluster(2, 1)
+ worker := c.WorkerName(0)
+
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ at0 := newRosterProbe(c, client, 0)
+ at1 := newRosterProbe(c, client, 1)
+ Eventually(at0.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(worker), at0.describe)
+
+ Expect(c.KillWorker(0)).To(Succeed())
+
+ // Assert the settled status, not the absence of a healthy name.
+ //
+ // A killed worker does not move monotonically. Measured: healthy until
+ // ~8s, unhealthy at 8s, healthy again at 14s, offline from 74s. The
+ // unhealthy blip comes from a liveness probe; the health monitor's
+ // "heartbeat is still fresh" branch then marks it healthy again
+ // (core/services/nodes/health.go), and only the stale-heartbeat branch
+ // reaches MarkOffline. So requiring exactly offline is what pins this to
+ // the stale-detection path rather than to the transient, which any
+ // not-healthy or not-present matcher would accept at t=8s.
+ //
+ // KNOWN HAZARD, read this before blaming the harness for a timeout here.
+ // The staleness branch skips a node that is already unhealthy
+ // (core/services/nodes/health.go:153-155, `if node.Status ==
+ // StatusOffline || node.Status == StatusUnhealthy { continue }`). The
+ // skip exists to stop the monitor re-logging nodes an operator took
+ // down, but it applies to the flap too: if the transient unhealthy mark
+ // lands AFTER the heartbeat has already gone stale, rather than at the
+ // ~8s observed here, MarkOffline is never called and this node stays
+ // unhealthy forever. This spec would then hang to workerDeathTimeout
+ // and fail with a roster that looks perfectly ordinary. The ordering
+ // that triggers it did not occur in any run so far, but nothing
+ // prevents it, so explainStuckOffline says so in the failure message
+ // when it sees a node stuck at unhealthy. Fixing it is LocalAI work,
+ // not test work.
+ // Reading the same verdict at both replicas proves shared-verdict
+ // propagation, NOT two independent detectors. Health checks are
+ // single-flighted by the advisory lock (see proveHealthCheckingIsAlive),
+ // so exactly one replica ran the check that wrote the offline status, and
+ // both probes then read that one Postgres row back. What this rules out
+ // is a replica that keeps a private roster, or one that reads the shared
+ // row and reports something else. A spec claiming both replicas can
+ // detect death on their own would have to isolate them from each other,
+ // which the shared database makes impossible by design.
+ for _, probe := range []*rosterProbe{at0, at1} {
+ Eventually(probe.statusOf, workerDeathTimeout, rosterPollInterval).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusOffline),
+ probe.explainStuckOffline(worker, "frontend %d never settled the dead worker to offline", probe.frontend))
+ }
+
+ // And it has to stay offline. Nothing may resurrect a row for a process
+ // that no longer exists, and this window covers three health ticks.
+ for _, probe := range []*rosterProbe{at0, at1} {
+ Consistently(probe.statusOf, settledStatusWindow, rosterPollInterval).
+ WithArguments(worker).
+ Should(Equal(nodes.StatusOffline),
+ probe.explain("frontend %d flipped the dead worker away from offline", probe.frontend))
+ }
+ })
+
+ It("converges on one roster when two replicas register a worker each", func() {
+ // SpreadWorkerRegistrations sends worker 0 to frontend 0 and worker 1 to
+ // frontend 1, so the roster is written through two different replicas.
+ //
+ // This is a shared-roster identity test, NOT a concurrency test, and the
+ // distinction matters because the obvious reading of the spec name is
+ // the wrong one. Start spawns workers one after another and waits for
+ // neither, and the registrations land about a second apart in practice;
+ // there is no synchronisation point and nothing here is tuned to make
+ // the two writes collide. What it does establish is that a roster
+ // written through two replicas is one roster and not two: same rows,
+ // same identities, read back from either process. A genuine concurrent
+ // registration test would need workers released together against a
+ // shared barrier, and does not exist yet.
+ c := startCluster(2, 2, func(o *cluster.Options) {
+ o.SpreadWorkerRegistrations = true
+ })
+ registrar0, err := c.WorkerRegistrar(0)
+ Expect(err).ToNot(HaveOccurred())
+ registrar1, err := c.WorkerRegistrar(1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(registrar0).ToNot(Equal(registrar1),
+ "both workers registered through the same replica, so nothing below says anything about two replicas sharing a roster")
+
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ at0 := newRosterProbe(c, client, 0)
+ at1 := newRosterProbe(c, client, 1)
+ expected := []string{c.WorkerName(0), c.WorkerName(1)}
+
+ // ConsistOf, not ContainElements: it fails on a third entry, which is
+ // how a duplicated row for one worker would show up.
+ Eventually(at0.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ConsistOf(expected), at0.describe)
+ Eventually(at1.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ConsistOf(expected), at1.describe)
+
+ // Same names is not the same roster. Compare the registration ids, which
+ // is the only way to tell "both replicas read one set of rows" from
+ // "each replica has its own row per worker that happens to share a
+ // name".
+ for _, name := range expected {
+ id := at0.idOf(name)
+ Expect(id).ToNot(BeEmpty(), fmt.Sprintf("frontend 0 reported %s without a registration ID", name))
+ Expect(at1.idOf(name)).To(Equal(id),
+ fmt.Sprintf("the replicas disagree on the identity of %s, so they are not sharing one roster", name))
+ }
+ })
+})
diff --git a/tests/e2e/distributed/cluster_peerlink_test.go b/tests/e2e/distributed/cluster_peerlink_test.go
new file mode 100644
index 000000000000..7c492c16b9e7
--- /dev/null
+++ b/tests/e2e/distributed/cluster_peerlink_test.go
@@ -0,0 +1,334 @@
+package distributed_test
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "strings"
+ "time"
+
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+
+ "github.com/libp2p/go-yamux/v5"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+)
+
+const (
+ // instanceRosterTimeout bounds the wait for a replica's row to appear.
+ // Registration is synchronous in startup, so this only has to cover the gap
+ // between /readyz answering and this spec's first query.
+ instanceRosterTimeout = "30s"
+ instanceRosterPoll = "500ms"
+
+ // deadReplicaTimeout bounds the wait for a survivor to reap a replica that
+ // was killed: the liveness window plus a sweep interval plus slack. It is
+ // deliberately derived from the constants rather than a round number, so
+ // tightening the window shortens the spec instead of leaving it passing for
+ // the wrong reason.
+ deadReplicaTimeout = clustersvc.InstanceLiveness + 4*clustersvc.InstanceHeartbeat
+
+ // peerDialTimeout bounds one peer dial. Every replica here is a local
+ // process, so a dial that needs longer has failed, not slowed.
+ peerDialTimeout = 20 * time.Second
+
+ // gracefulDepartureTimeout bounds the wait for a cleanly stopped replica to
+ // leave the table. It must stay well under InstanceLiveness, which the spec
+ // asserts: a budget that reached the window would pass on the sweeper doing
+ // the work and prove nothing about deregistration.
+ gracefulDepartureTimeout = 15 * time.Second
+
+ // peerRefusalTimeout bounds how long a refused stream may take to end. It
+ // is short on purpose: the refusal is one frame from a replica that has
+ // already decided, so a stream still open at this point is parked.
+ peerRefusalTimeout = 5 * time.Second
+
+ // unheldNodeID is a worker id no replica holds a tunnel for. It is a
+ // well-formed id rather than a nonsense string so the refusal it draws is
+ // the routing answer and not a parse failure.
+ unheldNodeID = "00000000-0000-0000-0000-00000000dead"
+)
+
+// openClusterDB connects to the database the cluster was given, so a spec can
+// read the tables the peer link keeps. Nothing serves them over HTTP: they are
+// replica-to-replica state, not an admin surface, and inventing an endpoint to
+// observe them would be a bigger change than the thing under test.
+func openClusterDB(dsn string) *gorm.DB {
+ GinkgoHelper()
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { closeDB(db) })
+ return db
+}
+
+// hostPortOf strips the scheme off a frontend URL, giving the form the
+// instances table stores.
+func hostPortOf(url string) string {
+ return strings.TrimPrefix(strings.TrimPrefix(url, "http://"), "https://")
+}
+
+// instanceRoster reads the live replica rows, keeping the last error so a
+// failing Eventually can name it.
+type instanceRoster struct {
+ registry *clustersvc.Registry
+ ctx context.Context
+
+ lastErr error
+ lastSaw []clustersvc.Instance
+}
+
+func newInstanceRoster(db *gorm.DB) *instanceRoster {
+ return &instanceRoster{registry: clustersvc.NewRegistry(db), ctx: context.Background()}
+}
+
+// addresses returns the advertised address of every live replica, or nil on a
+// query error so Eventually keeps trying.
+func (r *instanceRoster) addresses() []string {
+ live, err := r.registry.Live(r.ctx, clustersvc.InstanceLiveness)
+ if err != nil {
+ r.lastErr = err
+ return nil
+ }
+ r.lastErr = nil
+ r.lastSaw = live
+ addrs := []string{}
+ for _, instance := range live {
+ addrs = append(addrs, instance.AdvertisedAddr)
+ }
+ return addrs
+}
+
+// idAt returns the id of the live replica advertising addr, or "" if no such
+// row is present yet.
+func (r *instanceRoster) idAt(addr string) string {
+ for _, instance := range r.lastSaw {
+ if instance.AdvertisedAddr == addr {
+ return instance.ID
+ }
+ }
+ return ""
+}
+
+func (r *instanceRoster) describe() string {
+ if r.lastErr != nil {
+ return fmt.Sprintf("the last read of the instances table failed: %v", r.lastErr)
+ }
+ return fmt.Sprintf("the instances table held %d live replica(s): %+v", len(r.lastSaw), r.lastSaw)
+}
+
+// awaitReplicas waits for every frontend of c to publish its address and
+// returns the roster, positioned on that reading.
+func awaitReplicas(roster *instanceRoster, addrs ...string) {
+ GinkgoHelper()
+ Eventually(roster.addresses, instanceRosterTimeout, instanceRosterPoll).
+ Should(ConsistOf(addrs), roster.describe)
+}
+
+var _ = Describe("Cluster peer link", Label("Distributed"), Label("Cluster"), func() {
+ It("publishes an address for every replica that peers can actually dial", func() {
+ // A wrong implementation registers nothing (the whole of phase 1 had no
+ // call site until this spec), registers one row for two replicas, or
+ // records an address nothing can connect to: the bind address of a
+ // replica behind a service, or the loopback address the route to a
+ // co-located database would suggest.
+ c, dsn := startClusterOnFreshDB(2, 0)
+
+ roster := newInstanceRoster(openClusterDB(dsn))
+ awaitReplicas(roster, hostPortOf(c.FrontendURL(0)), hostPortOf(c.FrontendURL(1)))
+
+ // "Routable" is not a property of the string. Connect to each address,
+ // which is the only check that would have caught a replica publishing
+ // the port it was configured with rather than the one it serves on.
+ for _, instance := range roster.lastSaw {
+ conn, err := net.DialTimeout("tcp", instance.AdvertisedAddr, peerDialTimeout)
+ Expect(err).ToNot(HaveOccurred(),
+ "replica %s advertises %q, which nothing can connect to", instance.ID, instance.AdvertisedAddr)
+ Expect(conn.Close()).To(Succeed())
+ }
+ })
+
+ It("carries a peer stream between two replicas, and refuses one without the cluster token", func() {
+ // A wrong implementation fails here on WebSocket framing, which is the
+ // likeliest defect in the peer link: the adapter has to turn
+ // message-oriented WebSocket frames into the undelimited byte stream
+ // yamux drives. It also fails if the route was never registered on the
+ // real server, or if the global session middleware answers it: a peer
+ // carries no session and no user, only the cluster token.
+ //
+ // The stream is opened with the production dialler, resolving the peer
+ // through the production registry, over a real socket to a real
+ // process. This spec plays the sibling replica, because phase 1 has
+ // nothing that makes a frontend dial one on its own.
+ c, dsn := startClusterOnFreshDB(2, 0)
+
+ roster := newInstanceRoster(openClusterDB(dsn))
+ awaitReplicas(roster, hostPortOf(c.FrontendURL(0)), hostPortOf(c.FrontendURL(1)))
+
+ peerID := roster.idAt(hostPortOf(c.FrontendURL(1)))
+ Expect(peerID).ToNot(BeEmpty())
+
+ ctx, cancel := context.WithTimeout(context.Background(), peerDialTimeout)
+ defer cancel()
+
+ pool := clustersvc.NewPeerPool("e2e-peer", c.RegistrationToken(), roster.registry)
+ DeferCleanup(pool.Close)
+
+ // OpenStream is only acknowledged once the far side accepts, so this
+ // returning at all proves the frontend is accepting streams on the
+ // session it took, in addition to proving the handshake.
+ stream, err := pool.Open(ctx, peerID)
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { _ = stream.Close() })
+
+ // Phase 2 installs the relay on this link, so an accepted stream is one
+ // the peer is waiting to be told which worker it is for. Name one no
+ // replica holds and the refusal must come back at once.
+ //
+ // This spec used to assert the opposite, that an accepted stream ended
+ // immediately, because phase 1 had no relay to hand it to. The relay
+ // made that stale rather than wrong: a stream that says nothing now
+ // parks for relayHeaderTimeout, which is 15 seconds, and the old
+ // assertion failed on a five second budget against a replica behaving
+ // exactly as designed.
+ Expect(stream.SetWriteDeadline(time.Now().Add(peerRefusalTimeout))).To(Succeed())
+ Expect(clustersvc.WriteRelayRequest(stream, unheldNodeID, peerRefusalTimeout)).To(Succeed())
+
+ Expect(stream.SetReadDeadline(time.Now().Add(peerRefusalTimeout))).To(Succeed())
+ err = clustersvc.ReadRelayReply(stream)
+ Expect(err).To(MatchError(clustersvc.ErrNotOwner),
+ "the peer did not refuse a worker it does not hold: %v", err)
+ Expect(err).ToNot(MatchError(clustersvc.ErrNoConnection),
+ "a replica that does not hold a tunnel must not report the worker as absent: that is how a scheduler evicts a healthy worker")
+
+ // And the refusal ENDS the stream. A replica that says why and leaves
+ // the stream open has parked the caller on a request that will never be
+ // served, which reads as a slow replica rather than a refused request,
+ // and no deadline on the far side can tell those apart.
+ Expect(stream.SetReadDeadline(time.Now().Add(peerRefusalTimeout))).To(Succeed())
+ _, err = stream.Read(make([]byte, 1))
+ Expect(err).To(SatisfyAny(MatchError(io.EOF), MatchError(yamux.ErrStreamReset)),
+ "the peer refused the stream and then left it open: %v", err)
+
+ // The same dial with the wrong credentials must be refused, otherwise
+ // the success above says nothing about authentication.
+ impostor := clustersvc.NewPeerPool("e2e-peer", "not-the-cluster-token", roster.registry)
+ DeferCleanup(impostor.Close)
+ _, err = impostor.Open(ctx, peerID)
+ Expect(err).To(MatchError(clustersvc.ErrPeerUnreachable))
+ Expect(err).ToNot(MatchError(clustersvc.ErrInstanceNotFound),
+ "a peer refusing credentials is a live peer; reading it as absence is how a replica evicts healthy workers")
+ })
+
+ It("stops being dialled as soon as a replica shuts down cleanly", func() {
+ // The crash case below is handled by the sweeper, at the cost of a
+ // whole liveness window of peers dialling a corpse. A rolling update is
+ // not a crash: the replica knows it is leaving and says so. Without
+ // deregistration the two are indistinguishable, and every rolling
+ // restart spends that window failing peer dials for no reason.
+ c, dsn := startClusterOnFreshDB(2, 0)
+
+ roster := newInstanceRoster(openClusterDB(dsn))
+ awaitReplicas(roster, hostPortOf(c.FrontendURL(0)), hostPortOf(c.FrontendURL(1)))
+ departingID := roster.idAt(hostPortOf(c.FrontendURL(1)))
+ Expect(departingID).ToNot(BeEmpty())
+
+ Expect(c.StopFrontendGracefully(1)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(1) }, "20s", "500ms").Should(BeFalse())
+
+ // The budget is deliberately shorter than the liveness window: passing
+ // it proves the replica announced its departure rather than aged out.
+ Expect(gracefulDepartureTimeout).To(BeNumerically("<", clustersvc.InstanceLiveness))
+ Eventually(roster.addresses, gracefulDepartureTimeout, instanceRosterPoll).
+ Should(ConsistOf(hostPortOf(c.FrontendURL(0))), roster.describe)
+
+ // And absence is the RIGHT answer here, unlike the killed case: the
+ // replica said it was going. A caller may act on this.
+ ctx, cancel := context.WithTimeout(context.Background(), peerDialTimeout)
+ defer cancel()
+ pool := clustersvc.NewPeerPool("e2e-peer", c.RegistrationToken(), roster.registry)
+ DeferCleanup(pool.Close)
+ _, err := pool.Open(ctx, departingID)
+ Expect(err).To(MatchError(clustersvc.ErrInstanceNotFound))
+ })
+
+ It("reports a killed replica as unreachable, reaps what it owned, and evicts no worker", func() {
+ // This is the absence rule, pinned before phase 2 can depend on it. A
+ // wrong implementation lets a peer that will not answer surface as node
+ // absence, and a caller entitled to act on absence then reclaims what
+ // the peer was running: a network hiccup between two healthy replicas
+ // evicts healthy workers.
+ //
+ // It also pins the reaper: the connection rows a dead replica owned are
+ // swept by the same sweeper that decides the replica is dead, so the
+ // two can never disagree about who is alive.
+ c, dsn := startClusterOnFreshDB(2, 1)
+
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+
+ // The worker registers with frontend 0, so frontend 1 is the replica
+ // that can die without taking the worker's registrar with it.
+ registrar, err := c.WorkerRegistrar(0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(registrar).To(Equal(0), "this spec kills frontend 1 and needs the worker to have registered elsewhere")
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ workerID := probe.idOf(c.WorkerName(0))
+ Expect(workerID).ToNot(BeEmpty())
+
+ roster := newInstanceRoster(openClusterDB(dsn))
+ awaitReplicas(roster, hostPortOf(c.FrontendURL(0)), hostPortOf(c.FrontendURL(1)))
+ survivorID := roster.idAt(hostPortOf(c.FrontendURL(0)))
+ doomedID := roster.idAt(hostPortOf(c.FrontendURL(1)))
+ Expect(survivorID).ToNot(BeEmpty())
+ Expect(doomedID).ToNot(BeEmpty())
+
+ // Give frontend 1 the worker's tunnel. Phase 2 makes the worker do this
+ // by dialling; here the claim is written directly, because the point
+ // under test is what happens to the claim when its owner dies.
+ ctx := context.Background()
+ epoch, err := roster.registry.Claim(ctx, workerID, doomedID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(epoch).ToNot(BeZero())
+
+ Expect(c.KillFrontend(1)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(1) }, "20s", "500ms").Should(BeFalse())
+
+ // The row is still there for the whole liveness window, so this is the
+ // case that matters: the peer is KNOWN and will not answer.
+ dialCtx, cancel := context.WithTimeout(ctx, peerDialTimeout)
+ defer cancel()
+ pool := clustersvc.NewPeerPool("e2e-peer", c.RegistrationToken(), roster.registry)
+ DeferCleanup(pool.Close)
+ _, err = pool.Open(dialCtx, doomedID)
+ Expect(err).To(MatchError(clustersvc.ErrPeerUnreachable))
+ Expect(err).ToNot(MatchError(clustersvc.ErrInstanceNotFound),
+ "a dead replica whose row is still present is unreachable, not absent")
+
+ // The survivor sweeps the dead replica and, in the same pass, the claim
+ // it left behind.
+ Eventually(roster.addresses, deadReplicaTimeout, instanceRosterPoll).
+ Should(ConsistOf(hostPortOf(c.FrontendURL(0))), roster.describe)
+ ownerErr := func() error {
+ _, _, err := roster.registry.OwnerRow(ctx, workerID)
+ return err
+ }
+ Eventually(ownerErr, deadReplicaTimeout, instanceRosterPoll).
+ Should(MatchError(clustersvc.ErrNoConnection),
+ "the claim held by a replica that no longer exists was never reaped")
+
+ // And the worker survives the sweep that removed its owner. This is a
+ // window after the reaping, not a watch over the whole scenario:
+ // Consistently starts here, so what it rules out is the sweep, or
+ // anything reacting to it, taking the worker with it.
+ Consistently(probe.healthyNames, "6s", "1s").
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ })
+})
diff --git a/tests/e2e/distributed/cluster_tunnel_test.go b/tests/e2e/distributed/cluster_tunnel_test.go
new file mode 100644
index 000000000000..9301af5dfa69
--- /dev/null
+++ b/tests/e2e/distributed/cluster_tunnel_test.go
@@ -0,0 +1,973 @@
+package distributed_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/http/httputil"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "gorm.io/gorm"
+)
+
+// End-to-end proof that a worker with no inbound port is reachable, and only
+// through its tunnel.
+//
+// Every other spec for this feature drives the tunnel, the relay and the
+// ownership fence in isolation. These run the real binaries: a frontend replica
+// per process, a worker that binds nothing routable, and a real inference over
+// the result.
+//
+// Read the fourth scenario before trusting the first three. Frontend and worker
+// are the same host here, so the backend port the frontend names in a stream
+// target is a port the frontend could also have dialled directly; if it did,
+// the first three would pass with the tunnel doing nothing at all. The fourth
+// is what rules that out, and it is why the others mean anything.
+//
+// A fifth spec, in its own container below, measures what one session does when
+// a large message and ordinary inference share it.
+
+const (
+ // tunnelInferenceTimeout bounds one chat completion that has to install a
+ // backend on a worker, stage the model file over the tunnel and load it.
+ // Generous because the first request to a model pays for all of that.
+ tunnelInferenceTimeout = 3 * time.Minute
+
+ // tunnelOwnershipTimeout bounds the wait for a worker's tunnel to be
+ // claimed, or re-claimed after its owner died. A re-claim waits for the
+ // worker's own reconnect backoff, which is capped at tunnelBackoffMax
+ // (30s), plus the dial and the claim.
+ tunnelOwnershipTimeout = 90 * time.Second
+ tunnelOwnershipPoll = 500 * time.Millisecond
+
+ // tunnelRefusalTimeout bounds a request to a worker that has no tunnel, and
+ // is what tells a REFUSED request from a PARKED one: resolving the route
+ // fails on a table read, so a request that has not come back by now is not
+ // slow, it is waiting on something that will never happen.
+ //
+ // It says nothing about the refusal being the RIGHT one. A 503 saying the
+ // model is still loading would come back inside it too; what rules that out
+ // is the assertion on what the body says.
+ tunnelRefusalTimeout = 60 * time.Second
+
+ // mockedReply is what the mock backend answers a prompt carrying no
+ // directive. Asserted rather than merely "some content", so a frontend that
+ // answered from a cache, an error template or a local backend of its own
+ // cannot satisfy these specs.
+ mockedReply = "This is a mocked response."
+
+ // tunnelRestoredTimeout bounds the wait for inference to work again after a
+ // route came back. It has to clear loadJobFailureGrace, which replays a
+ // failed cold load's error to every caller for 15 seconds.
+ tunnelRestoredTimeout = 90 * time.Second
+ tunnelRestoredPoll = 2 * time.Second
+)
+
+// mockModelYAML is a model configuration served by the mock backend. The
+// artifact is a real file so the frontend's file staging has something to send
+// over the tunnel, which is the http-tagged half of this feature.
+func mockModelYAML(name string) string {
+ return fmt.Sprintf("name: %s\nbackend: mock-backend\nparameters:\n model: %s.bin\n", name, name)
+}
+
+// chatResult is one completion attempt: what the frontend answered and how long
+// it took. Both halves are used, the status by the correctness specs and the
+// duration by the head-of-line measurement.
+type chatResult struct {
+ status int
+ body string
+ content string
+ elapsed time.Duration
+}
+
+// chat posts one non-streaming completion and reports what came back. It never
+// fails the spec itself: a refusal is the expected answer in two of these
+// specs, so the caller decides what the status means.
+func chat(client *http.Client, baseURL, model, prompt string) (chatResult, error) {
+ body, err := json.Marshal(map[string]any{
+ "model": model,
+ "messages": []map[string]string{{"role": "user", "content": prompt}},
+ })
+ if err != nil {
+ return chatResult{}, err
+ }
+ started := time.Now()
+ resp, err := client.Post(baseURL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
+ if err != nil {
+ return chatResult{elapsed: time.Since(started)}, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return chatResult{status: resp.StatusCode, elapsed: time.Since(started)}, err
+ }
+ result := chatResult{status: resp.StatusCode, body: string(raw), elapsed: time.Since(started)}
+
+ var parsed struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if json.Unmarshal(raw, &parsed) == nil && len(parsed.Choices) > 0 {
+ result.content = parsed.Choices[0].Message.Content
+ }
+ return result, nil
+}
+
+// eventuallyMockedInference retries one completion until the worker answers.
+//
+// It exists for the two specs that restore a route and then assert it works
+// again. A failed cold load is REPLAYED to every caller for loadJobFailureGrace
+// (15s, core/services/nodes/model_load_job.go) so that a failure does not turn
+// into a retry storm, which means the first request after a route comes back
+// gets the stale reason rather than a fresh attempt. Retrying against the real
+// condition is what a client does, and it keeps the spec off a sleep.
+func eventuallyMockedInference(client *http.Client, baseURL, model, why string) {
+ GinkgoHelper()
+ last := ""
+ Eventually(func() string {
+ result, err := chat(client, baseURL, model, "ping")
+ if err != nil {
+ last = err.Error()
+ return ""
+ }
+ last = fmt.Sprintf("status %d: %s", result.status, result.body)
+ if result.status != http.StatusOK {
+ return ""
+ }
+ return result.content
+ }, tunnelRestoredTimeout, tunnelRestoredPoll).Should(Equal(mockedReply),
+ func() string { return why + ": the last attempt answered " + last })
+}
+
+// inferenceClient is ONE admin session for the whole cluster, with a budget
+// long enough for a cold model load across a tunnel.
+//
+// One per spec, never one per frontend. The auth routes share a limiter of five
+// requests per minute per client IP and every request here comes from
+// 127.0.0.1, so a helper that minted a session per frontend would spend that
+// budget and start failing setup in specs that touch three replicas. The client
+// is good at every replica anyway: sessions live in the shared Postgres, the
+// harness pins one HMAC secret across replicas, and Go's cookie jar keys by
+// host without the port. It is also safe to use from several goroutines, which
+// the load measurement needs.
+func inferenceClient(c *cluster.Cluster) *http.Client {
+ GinkgoHelper()
+ client, err := c.AdminSession(0)
+ Expect(err).ToNot(HaveOccurred())
+ client.Timeout = tunnelInferenceTimeout
+ return client
+}
+
+// expectMockedInference runs one completion and requires the worker's own
+// answer. It is the assertion every positive scenario ends on.
+func expectMockedInference(client *http.Client, baseURL, model, why string) chatResult {
+ GinkgoHelper()
+ result, err := chat(client, baseURL, model, "ping")
+ Expect(err).ToNot(HaveOccurred(), why)
+ Expect(result.status).To(Equal(http.StatusOK), "%s: %s", why, result.body)
+ Expect(result.content).To(Equal(mockedReply),
+ "%s: the frontend answered 200 but not with the worker's reply: %s", why, result.body)
+ return result
+}
+
+// tunnelOwners reads which replica holds which worker's tunnel.
+//
+// It reads the node_connections table through the production Owner query, which
+// joins against live instances, so a row left behind by a dead replica is not
+// reported as an owner. Nothing serves this over HTTP; it is replica-to-replica
+// state.
+type tunnelOwners struct {
+ registry *clustersvc.Registry
+ roster *instanceRoster
+ ctx context.Context
+
+ lastErr error
+}
+
+func newTunnelOwners(db *gorm.DB) *tunnelOwners {
+ return &tunnelOwners{
+ registry: clustersvc.NewRegistry(db),
+ roster: newInstanceRoster(db),
+ ctx: context.Background(),
+ }
+}
+
+// ownerOf returns the instance ID of the live replica holding nodeID's tunnel,
+// or "" when there is none. Errors are kept rather than raised so an Eventually
+// can name the last one.
+func (o *tunnelOwners) ownerOf(nodeID string) string {
+ owner, _, err := o.registry.Owner(o.ctx, nodeID)
+ if err != nil {
+ o.lastErr = err
+ return ""
+ }
+ o.lastErr = nil
+ return owner
+}
+
+// ownerIndexOf is ownerOf expressed as a frontend index of c, or -1 when no
+// live replica holds the tunnel.
+//
+// The mapping goes through the advertised address, which the harness pins to
+// each replica's own loopback port, so it is exact rather than a guess. A
+// spec asserting "this request went to the replica that does not own the
+// worker" needs the index and not the opaque instance ID.
+func (o *tunnelOwners) ownerIndexOf(c *cluster.Cluster, frontends int, nodeID string) int {
+ owner := o.ownerOf(nodeID)
+ if owner == "" {
+ return -1
+ }
+ // Refreshes o.roster.lastSaw, which idAt reads.
+ o.roster.addresses()
+ for i := 0; i < frontends; i++ {
+ if o.roster.idAt(hostPortOf(c.FrontendURL(i))) == owner {
+ return i
+ }
+ }
+ return -1
+}
+
+func (o *tunnelOwners) describe() string {
+ if o.lastErr != nil {
+ return fmt.Sprintf("the last read of the tunnel owner failed: %v", o.lastErr)
+ }
+ return fmt.Sprintf("live replicas: %s", o.roster.describe())
+}
+
+// frontendBalancer stands in for the load balancer a worker dials in
+// production.
+//
+// It exists because LOCALAI_REGISTER_TO is BOTH the registration endpoint and
+// the tunnel endpoint, and the worker resolves it once at boot and never again.
+// Pointed straight at a replica, a worker whose replica dies can never come
+// back, so the re-home this feature is built on cannot happen; and there is no
+// other way to let a worker register normally while its tunnel dial fails,
+// because LOCALAI_WORKER_TUNNEL=false is refused at startup.
+//
+// Two behaviours, both needed:
+//
+// - It forwards to the FIRST target that accepts a connection, which is what
+// re-homes a worker onto the survivor after its replica is killed.
+// - With blockTunnel set it answers the tunnel connect path itself, with the
+// status a frontend that holds no tunnels gives, while still forwarding
+// registration and heartbeats. That is the suite's negative control.
+//
+// tunnelDials counts what it saw on that path, so a spec can assert the worker
+// really tried and really was refused rather than assuming it.
+type frontendBalancer struct {
+ server *httptest.Server
+ targets []*url.URL
+ blockTunnel atomic.Bool
+ tunnelDials atomic.Int64
+}
+
+// balancerProbeTimeout bounds the liveness probe the director makes per
+// request. Every target is a local process, so a refused connection comes back
+// at once and this only bounds the pathological case.
+const balancerProbeTimeout = 2 * time.Second
+
+// newFrontendBalancer starts a balancer in front of the given frontend URLs, in
+// the order it should prefer them.
+func newFrontendBalancer(urls ...string) *frontendBalancer {
+ GinkgoHelper()
+ b := &frontendBalancer{}
+ for _, raw := range urls {
+ parsed, err := url.Parse(raw)
+ Expect(err).ToNot(HaveOccurred())
+ b.targets = append(b.targets, parsed)
+ }
+
+ proxy := &httputil.ReverseProxy{
+ Director: func(r *http.Request) {
+ target := b.pick()
+ r.URL.Scheme = target.Scheme
+ r.URL.Host = target.Host
+ r.Host = target.Host
+ },
+ // A dead target is the ordinary case here, not an incident: the
+ // director picked it and it died between the probe and the dial.
+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
+ http.Error(w, fmt.Sprintf("balancer: no frontend answered: %v", err), http.StatusBadGateway)
+ },
+ }
+
+ b.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, clustersvc.ConnectPath) {
+ b.tunnelDials.Add(1)
+ if b.blockTunnel.Load() {
+ // The status a frontend not running in distributed mode gives.
+ // The worker retries it with backoff and stays otherwise
+ // healthy, which is precisely the state the negative control
+ // needs: registered, heartbeating, and holding no tunnel.
+ http.Error(w, "balancer: worker tunnels are blocked for this spec", http.StatusServiceUnavailable)
+ return
+ }
+ }
+ proxy.ServeHTTP(w, r)
+ }))
+ DeferCleanup(b.server.Close)
+ return b
+}
+
+// URL is what a worker should be given as its frontend.
+func (b *frontendBalancer) URL() string { return b.server.URL }
+
+// pick returns the first target that accepts a connection, falling back to the
+// first so a request during a total outage fails at the proxy with a status
+// rather than panicking in the director.
+func (b *frontendBalancer) pick() *url.URL {
+ for _, target := range b.targets {
+ conn, err := net.DialTimeout("tcp", target.Host, balancerProbeTimeout)
+ if err == nil {
+ _ = conn.Close()
+ return target
+ }
+ }
+ return b.targets[0]
+}
+
+var _ = Describe("Worker tunnel end to end", Label("Distributed"), Label("Cluster"), func() {
+ // Scenario 1. A wrong implementation reaches the worker some other way, or
+ // cannot reach it at all. The worker binds nothing routable and advertises
+ // nothing, so the assertion on its empty advertisement is what says there
+ // is nothing else the frontend could have been given.
+ It("reaches a worker that advertises no address, through its tunnel", func() {
+ c, dsn := startClusterOnFreshDB(1, 1, withMockModel("mock-model"))
+ client := inferenceClient(c)
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // The worker publishes no endpoint of any kind. Without this the
+ // inference below would be satisfied by a frontend that dialled an
+ // advertised address, which is the path this phase removed.
+ //
+ // Both halves are needed. An empty value alone would also be what a
+ // spec sees after the keys are renamed or dropped from the payload,
+ // and that would leave this reporting "advertises nothing" about a
+ // node it can no longer see the advertisement of at all.
+ advertised, carriedKeys := probe.advertisementOf(c.WorkerName(0))
+ Expect(carriedKeys).To(BeTrue(),
+ "the roster payload no longer carries the address and http_address keys, so this spec cannot tell a worker that advertises nothing from one it cannot read")
+ Expect(advertised).To(BeEmpty(),
+ "the worker advertised %q, so this spec cannot tell a tunnelled request from a direct dial", advertised)
+
+ // And its tunnel is held HERE, so the request below is served by the
+ // owner rather than relayed. The relay is the next spec's subject.
+ owners := newTunnelOwners(openClusterDB(dsn))
+ Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(0), owners.describe)
+
+ expectMockedInference(client, c.FrontendURL(0), "mock-model",
+ "a worker with no advertised address must still serve inference over its tunnel")
+ })
+
+ // Scenario 2. With N replicas behind round robin this is (N-1)/N of
+ // production traffic. A wrong implementation answers it by dialling the
+ // worker from the replica that took the request, which works on one host
+ // and nowhere else, or refuses it as a worker that is not connected.
+ It("serves a request that landed on the replica which does not own the worker", func() {
+ c, dsn := startClusterOnFreshDB(2, 1, withMockModel("relayed-model"))
+ client := inferenceClient(c)
+
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // Which replica owns the tunnel is READ, not assumed. The harness sends
+ // the worker to frontend 0 by default, but that is a harness default
+ // and a spec that assumed it would keep passing after the default
+ // changed while silently testing the owner path instead.
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+
+ // The one replica that is not the owner. With two frontends there is
+ // exactly one, and it is derived from the reading above rather than
+ // written down, so this spec exercises the relay whichever replica the
+ // worker landed on.
+ nonOwner := 1 - owner
+ Expect(nonOwner).ToNot(Equal(owner))
+
+ // The request is about to go to a replica the database says does not
+ // hold this worker's tunnel. Read again here rather than inferred from
+ // the reading above, so a spec that had derived the index some other
+ // way still could not send it to the owner.
+ //
+ // This does NOT close the race, and saying that it does would be the
+ // same overclaim this phase has had to retract twice: ownership can
+ // move between this read and the reply, and if it moved TO nonOwner the
+ // request would be served directly and still come back 200. What closes
+ // it is the trailing read after the request, which requires the owner to
+ // be unchanged; a move to nonOwner leaves that read returning nonOwner
+ // and reddens the spec. This one rules out only the arrangement being
+ // wrong from the start, which is the cheaper half.
+ Expect(owners.ownerIndexOf(c, 2, nodeID)).ToNot(Equal(nonOwner),
+ "frontend %d owns the worker's tunnel, so a request to it would not be relayed and this spec would prove nothing", nonOwner)
+
+ // The FIRST request for this model goes to the non-owner, so the
+ // backend install, the model file staging over the http tag and the
+ // gRPC load and predict all cross the relay. Warming the model up at
+ // the owner first would leave only the predict on the relayed path.
+ expectMockedInference(client, c.FrontendURL(nonOwner), "relayed-model",
+ fmt.Sprintf("frontend %d must relay to frontend %d, which owns the worker's tunnel", nonOwner, owner))
+
+ // THIS is the assertion that makes the request above a relayed one.
+ //
+ // It rules out the two ways a 200 could arrive without a relay: a
+ // replica that answered by taking the tunnel for itself, and the
+ // tunnel moving to nonOwner mid-request so that it served directly.
+ // Both leave the owner changed, and both redden here. The only window
+ // left is a move away and back inside one request, which takes two
+ // claims, and no replica dies in this scenario to prompt either.
+ Expect(owners.ownerIndexOf(c, 2, nodeID)).To(Equal(owner),
+ "the tunnel is no longer held by frontend %d, so the request to frontend %d was not necessarily relayed", owner, nonOwner)
+ })
+
+ // Scenario 3. Kills the replica holding the tunnel. The worker must land on
+ // the survivor and serve again. A wrong implementation leaves the dead
+ // replica's connection row in place, so the survivor relays into a corpse,
+ // or lets the re-claim be fenced out by its own stale epoch.
+ It("re-homes a worker onto the survivor when the replica holding its tunnel dies", func() {
+ // The worker dials a balancer rather than a replica: in production
+ // LOCALAI_REGISTER_TO is the load balancer, and a worker pointed at one
+ // replica has nowhere to reconnect to when that replica dies.
+ var balancer *frontendBalancer
+ c, dsn := startClusterOnFreshDB(2, 1, withMockModel("failover-model"), withBalancer(&balancer))
+
+ client := inferenceClient(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+
+ survivor := 1 - owner
+ expectMockedInference(client, c.FrontendURL(owner), "failover-model",
+ "inference must work before the owner is killed, or the recovery below proves nothing")
+
+ Expect(c.KillFrontend(owner)).To(Succeed())
+ Eventually(func() bool { return c.FrontendAlive(owner) }, "20s", "500ms").Should(BeFalse())
+
+ // The re-home is the assertion, not the inference. A frontend that
+ // answered without the tunnel moving would satisfy an inference-only
+ // spec while the worker stayed stranded on a dead replica.
+ Eventually(func() int { return owners.ownerIndexOf(c, 2, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(survivor), owners.describe)
+
+ // Read at the SURVIVOR. The probe above is bound to the replica that was
+ // just killed, and a roster read against a dead process reports nothing
+ // rather than reporting a node that went away.
+ atSurvivor := newRosterProbe(c, client, survivor)
+ Eventually(atSurvivor.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), atSurvivor.describe)
+
+ // Same worker process, not a replacement: the node ID is the identity
+ // registration minted, and a worker that had restarted and re-registered
+ // would be a different story with the same ending.
+ Expect(atSurvivor.idOf(c.WorkerName(0))).To(Equal(nodeID),
+ "the worker re-registered rather than re-homing, so this proves nothing about the tunnel moving")
+
+ eventuallyMockedInference(client, c.FrontendURL(survivor), "failover-model",
+ "the survivor must serve the re-homed worker")
+ })
+
+ // Scenario 4. THE NEGATIVE CONTROL FOR THE WHOLE SUITE.
+ //
+ // Frontend and worker share a host here, so every backend port named in a
+ // stream target is one the frontend could dial directly. If it did, the
+ // three specs above would pass with the tunnel doing nothing. This one
+ // takes the tunnel away and requires the worker to become unreachable,
+ // while leaving registration, heartbeats and the roster untouched.
+ //
+ // It cannot use LOCALAI_WORKER_TUNNEL=false: that is refused at startup
+ // now, and a worker that never started says nothing about a worker that is
+ // reachable by some other path. The balancer refuses the tunnel dial
+ // instead, which leaves a worker that is registered, healthy and holding
+ // no tunnel.
+ It("cannot reach a worker whose tunnel is refused, and can as soon as it is not", func() {
+ var balancer *frontendBalancer
+ c, dsn := startClusterOnFreshDB(1, 1, withMockModel("controlled-model"),
+ withBalancer(&balancer, func(b *frontendBalancer) { b.blockTunnel.Store(true) }))
+
+ client := inferenceClient(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ // The worker is in every respect the one the specs above used: same
+ // binary, same environment, registered and healthy. The only difference
+ // is the tunnel, and both halves of that are asserted rather than
+ // assumed: it tried, and no replica holds it.
+ Eventually(balancer.tunnelDials.Load, "60s", "500ms").Should(BeNumerically(">", 0),
+ "the worker never dialled its tunnel, so blocking the dial is not what makes it unreachable below")
+ owners := newTunnelOwners(openClusterDB(dsn))
+ Consistently(func() string { return owners.ownerOf(nodeID) }, "5s", "500ms").Should(BeEmpty(),
+ "a replica holds this worker's tunnel, so the blocker is not blocking")
+
+ client.Timeout = tunnelRefusalTimeout
+ refused, err := chat(client, c.FrontendURL(0), "controlled-model", "ping")
+ Expect(err).ToNot(HaveOccurred(),
+ "the request never came back; a worker with no route must be refused, not left parked")
+ Expect(refused.status).ToNot(Equal(http.StatusOK),
+ "the frontend served an inference for a worker that holds no tunnel, so something other than the tunnel reaches it: %s", refused.body)
+
+ // And it fails for the RIGHT reason. A frontend refusing for any other
+ // cause (a missing model, a backend it could not install, an unhealthy
+ // node) would satisfy the assertion above just as well, and would leave
+ // the three specs before this one unproven.
+ //
+ // One substring, not a disjunction. This is the strongest leg of the
+ // whole control, and a disjunction is where such a leg goes soft: the
+ // looser alternatives this used to carry ("tunnel", "not connected",
+ // "unroutable") would each be satisfied by refusals that say nothing
+ // about routing, and one of them is a word this deployment's messages
+ // are full of. "no route" is what cluster.ErrNoRoute reads as, and
+ // nothing else on this path produces it.
+ Expect(refused.body).To(ContainSubstring("no route"),
+ "the refusal does not name the missing route, so this spec cannot tell a worker with no tunnel from a request that failed for one of the ordinary reasons: %s", refused.body)
+
+ // The control's own control: put the tunnel back, change nothing else,
+ // and the same request must now succeed. Without this the refusal above
+ // could be any of the ordinary reasons an e2e inference fails.
+ balancer.blockTunnel.Store(false)
+ Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
+ Should(Equal(0), owners.describe)
+
+ client.Timeout = tunnelInferenceTimeout
+ eventuallyMockedInference(client, c.FrontendURL(0), "controlled-model",
+ "the only thing that changed is the tunnel, so the refusal above was the missing tunnel and nothing else")
+ })
+})
+
+// withMockModel seeds one mock-backend model configuration and its artifact
+// into every frontend.
+func withMockModel(name string) func(*cluster.Options) {
+ return func(o *cluster.Options) {
+ if o.Models == nil {
+ o.Models = map[string]string{}
+ }
+ o.Models[name+".yaml"] = mockModelYAML(name)
+ o.Models[name+".bin"] = tinyArtifact()
+ }
+}
+
+// withBalancer sends every worker through one balancer in front of all the
+// frontends, and publishes it at into so the spec can drive it.
+//
+// The balancer is built inside the hook rather than beside the cluster because
+// it needs the frontends' ports, and those exist only once Start has brought
+// them up; the hook runs per worker, after that. arm runs on the balancer the
+// moment it exists, which is before the worker process is spawned, so a spec
+// that needs the tunnel blocked from the very first dial can say so without
+// racing the worker's first attempt.
+func withBalancer(into **frontendBalancer, arm ...func(*frontendBalancer)) func(*cluster.Options) {
+ return func(o *cluster.Options) {
+ o.WorkerFrontendURL = func(_ int, _ string, frontends []string) string {
+ if *into == nil {
+ *into = newFrontendBalancer(frontends...)
+ for _, apply := range arm {
+ apply(*into)
+ }
+ }
+ return (*into).URL()
+ }
+ }
+}
+
+// percentileIndex is where the p'th percentile of n sorted samples falls, or
+// -1 when there are none.
+func percentileIndex(n int, p float64) int {
+ if n == 0 {
+ return -1
+ }
+ return int(float64(n-1) * p)
+}
+
+// slowestOf is the worst sample, which is the statistic a blocking question
+// turns on: a session that stalls one request while a transfer holds it shows
+// up in the tail and not in the middle.
+func slowestOf(samples []time.Duration) time.Duration {
+ worst := time.Duration(0)
+ for _, d := range samples {
+ if d > worst {
+ worst = d
+ }
+ }
+ return worst
+}
+
+// sortedCopy returns samples in ascending order without disturbing the caller's
+// slice, which the report entry reads again afterwards.
+func sortedCopy(samples []time.Duration) []time.Duration {
+ sorted := append([]time.Duration(nil), samples...)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
+ return sorted
+}
+
+// summarise reports the shape of a latency sample.
+func summarise(label string, samples []time.Duration) string {
+ if len(samples) == 0 {
+ return label + ": no samples"
+ }
+ sorted := sortedCopy(samples)
+ var total time.Duration
+ for _, d := range sorted {
+ total += d
+ }
+ out := fmt.Sprintf("%s: n=%d mean=%s", label, len(sorted), total/time.Duration(len(sorted)))
+ // A quantile is printed only when the sample can separate it from the ones
+ // already printed and from the max. At the sizes here, n around 20 to 50,
+ // p90 and p99 routinely land on the same element and p99 often lands on the
+ // last one, and printing one number three times under three names invites a
+ // reader to compare a tail nothing measured. Omission says "this sample
+ // cannot answer that"; a repeated number says the opposite.
+ printed := map[int]bool{percentileIndex(len(sorted), 1): true}
+ for _, q := range []struct {
+ name string
+ p float64
+ }{{"p50", 0.50}, {"p90", 0.90}, {"p99", 0.99}} {
+ idx := percentileIndex(len(sorted), q.p)
+ if printed[idx] {
+ continue
+ }
+ printed[idx] = true
+ out += fmt.Sprintf(" %s=%s", q.name, sorted[idx])
+ }
+ return out + fmt.Sprintf(" max=%s", sorted[len(sorted)-1])
+}
+
+// The deferred question of this phase: what one yamux session does when a large
+// message and ordinary inference share it, with the relay adding a second hop
+// for most requests.
+//
+// It is MEASURED here rather than asserted to be fine, and the numbers are
+// printed as a report entry so a later change has something to be compared
+// against.
+//
+// Be exact about which windows those numbers do and do not speak for. The
+// WORKER TUNNEL's two ends both take yamux's defaults (core/services/worker,
+// tunnel.go, and core/http/endpoints/cluster/connect.go), and this measurement
+// is no reason to change that, but it is also no evidence that they are right:
+// a default receive window is limited by the bandwidth-delay product of the
+// link, and loopback has no delay to produce one. The PEER LINK's windows are raised
+// on both ends. What is measured here is whether a session SERIALISES, which
+// loopback answers perfectly well, and not whether a window is large enough for
+// a link with latency, which it cannot answer at all.
+const (
+ // bulkArtifactSize is the model artifact staged over the tunnel while
+ // probes run. It stands in for the 50MB-class message this feature has to
+ // carry: real model files are far larger, and if a session cannot interleave
+ // at this size it certainly cannot at theirs.
+ //
+ // It is sized against the CONTROL below rather than for realism alone. A
+ // cold load costs a few hundred milliseconds before a byte moves (backend
+ // install, then the load itself), so at a smaller size the transfer is a
+ // minority of the window being measured and a spec could report a clean
+ // bill from a window that was mostly not a transfer. That is not
+ // hypothetical: the first version of this spec used 64MiB and still passed
+ // with the artifact cut to 4KiB, which is the definition of measuring
+ // nothing.
+ //
+ // What it costs, since it is the largest thing this suite puts on disk:
+ // two bulk models seeded into each of two frontends is 512 MiB, and each is
+ // then staged to the worker, which is 256 MiB more. About 768 MiB under
+ // TMPDIR for the length of this spec, plus one 128 MiB string resident in
+ // the test process. The harness removes the tree in Stop.
+ bulkArtifactSize = 128 << 20
+
+ // tinyArtifactSize is the same cold load with nothing to transfer. It is
+ // what the bulk window is measured AGAINST, so that the part of the window
+ // attributable to moving bytes is a number this spec holds rather than an
+ // assumption about the load path.
+ tinyArtifactSize = 4 << 10
+
+ // minTransferWindow is how much longer the bulk cold load must take than
+ // the tiny one before the numbers below mean anything. It is THE control on
+ // this measurement: without it a bulk artifact that shrank, or a staging
+ // path that stopped transferring, would leave the spec reporting that a
+ // large message does not block inference having sent no large message.
+ minTransferWindow = 100 * time.Millisecond
+
+ // holProbeCount is how many completions the baseline is measured over.
+ holProbeCount = 40
+
+ // minOverlappingProbes is the measurement's own negative control. A bulk
+ // transfer that finishes before any probe ran would report "no head-of-line
+ // blocking" having measured nothing at all, which is the shape of vacuous
+ // result this phase keeps producing. Below this the spec fails rather than
+ // reporting.
+ minOverlappingProbes = 5
+
+ // holStallShare bounds the worst probe as a fraction of the window in which
+ // bytes were moving. It is the STRUCTURAL assertion: a session that
+ // head-of-line blocks parks a probe until the transfer lets go, so a
+ // stalled probe's latency is on the order of the whole window, and one that
+ // interleaves finishes many probes inside it.
+ //
+ // Half, not the whole window. Bounding by the window itself admits a probe
+ // that took nearly all of it, which is the wedge with the numbers filed
+ // off.
+ //
+ // Not tighter than half, and the reason is measured rather than cautious. A
+ // probe's tail grows faster than the transfer window does when the box is
+ // busy: under a concurrent `-race` suite the worst relayed probe reached
+ // 20% of its window here, so a quarter would have had 1.2x of margin and a
+ // spec that fails one run in three is worse than no spec. Half leaves 2.4x
+ // on the same run and still reddens on a stall, which parks a probe for the
+ // window rather than a fifth of it.
+ holStallShare = 2
+
+ // holStallControlFactor bounds the worst probe against the worst probe
+ // under the EMPTY load in the same run, which is the second half of not
+ // relaxing under load: a slower box raises the control and the bound with
+ // it, while an absolute number would simply admit more.
+ //
+ // The empty load is the right thing to compare against and the plain
+ // baseline is not. Both samples then contain a cold load's contention for
+ // the worker, the router and the session, and the only thing that differs
+ // between them is 128 MiB crossing the wire. Compared against the quiet
+ // baseline instead, a transfer that cost nothing at all would still look
+ // like a regression on any box where a cold load is expensive.
+ //
+ // Eight, from both ends of the gap it has to sit in. Healthy runs measured
+ // 1.6x to 3.8x on this box under a concurrent `-race` suite, and about 2.5x
+ // to 3x on the reviewer's; a session that stalled a probe until the
+ // transfer let go would show the whole window over the same control, which
+ // is 14x to 43x on the same runs.
+ holStallControlFactor = 8
+
+ // holStallCeiling is the coarse absolute backstop under both of those, for
+ // a transfer so slow that a quarter of its window is a latency no
+ // deployment would tolerate.
+ //
+ // It is deliberately far above anything measured rather than tuned, because
+ // an absolute number cannot separate a wedge from a slow box. Measured
+ // worst probe and transfer window, for scale: 21-36ms against 261-576ms on
+ // the box this was written on, and 70-136ms against 590-1320ms on the
+ // reviewer's. An absolute ceiling that bit on the first machine's wedge
+ // would fail on the second machine's healthy run, which is why the two
+ // relative bounds above are the assertions and this is only a floor.
+ holStallCeiling = 5 * time.Second
+)
+
+// bulkArtifact is the large model artifact, built once. Both bulk models share
+// the string: two copies of it would be two more allocations of
+// bulkArtifactSize in the test process for no gain, since what is measured is
+// the transfer and not the bytes.
+var bulkArtifact = sync.OnceValue(func() string {
+ block := strings.Repeat("localai-tunnel-payload-", 45) + "\n" // ~1KiB
+ return strings.Repeat(block, bulkArtifactSize/len(block)+1)[:bulkArtifactSize]
+})
+
+// tinyArtifact is the artifact of a model that costs a cold load and no
+// transfer.
+func tinyArtifact() string {
+ return strings.Repeat("x", tinyArtifactSize)
+}
+
+// probeOnce runs one completion against a warm model and reports how long it
+// took, failing on anything but the worker's own answer: a probe that measured
+// an error response would report a latency for work that never crossed the
+// tunnel.
+func probeOnce(client *http.Client, baseURL, model string) (time.Duration, error) {
+ result, err := chat(client, baseURL, model, "ping")
+ if err != nil {
+ return 0, err
+ }
+ if result.status != http.StatusOK {
+ return 0, fmt.Errorf("probe answered %d: %s", result.status, result.body)
+ }
+ if result.content != mockedReply {
+ return 0, fmt.Errorf("probe answered 200 but not with the worker's reply: %s", result.body)
+ }
+ return result.elapsed, nil
+}
+
+// probeN runs n completions back to back. This is the baseline: one request in
+// flight at a time, nothing else on the session.
+func probeN(client *http.Client, baseURL, model string, n int) ([]time.Duration, error) {
+ samples := make([]time.Duration, 0, n)
+ for i := 0; i < n; i++ {
+ elapsed, err := probeOnce(client, baseURL, model)
+ if err != nil {
+ return samples, err
+ }
+ samples = append(samples, elapsed)
+ }
+ return samples, nil
+}
+
+// probeUntil runs completions back to back until stop closes. Same shape as
+// probeN, so the two samples differ only in what else was on the session.
+func probeUntil(client *http.Client, baseURL, model string, stop <-chan struct{}) ([]time.Duration, error) {
+ var samples []time.Duration
+ for {
+ select {
+ case <-stop:
+ return samples, nil
+ default:
+ }
+ elapsed, err := probeOnce(client, baseURL, model)
+ if err != nil {
+ return samples, err
+ }
+ samples = append(samples, elapsed)
+ }
+}
+
+var _ = Describe("Worker tunnel under load", Label("Distributed"), Label("Cluster"), func() {
+ It("interleaves inference with a bulk transfer on one session, direct and relayed", func() {
+ c, dsn := startClusterOnFreshDB(2, 1,
+ withMockModel("hol-probe"),
+ withMockModel("hol-tiny-direct"),
+ withMockModel("hol-tiny-relayed"),
+ withBulkModel("hol-bulk-direct"),
+ withBulkModel("hol-bulk-relayed"))
+
+ client := inferenceClient(c)
+ probe := newRosterProbe(c, client, 0)
+ Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
+ Should(ContainElement(c.WorkerName(0)), probe.describe)
+ nodeID := probe.idOf(c.WorkerName(0))
+ Expect(nodeID).ToNot(BeEmpty())
+
+ owners := newTunnelOwners(openClusterDB(dsn))
+ var owner int
+ Eventually(func() int {
+ owner = owners.ownerIndexOf(c, 2, nodeID)
+ return owner
+ }, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
+ nonOwner := 1 - owner
+
+ // Warm the probe model on the worker. Everything measured below is the
+ // warm path, so that a probe's latency is the session's and not a cold
+ // load's.
+ expectMockedInference(client, c.FrontendURL(owner), "hol-probe",
+ "the probe model must load before anything is measured")
+
+ report := []string{}
+
+ // coldLoadUnderProbes runs one cold load of model, keeps probing the
+ // warm model until it finishes, and reports both.
+ coldLoadUnderProbes := func(at, model string) (time.Duration, []time.Duration) {
+ GinkgoHelper()
+ done := make(chan struct{})
+ var result chatResult
+ var loadErr error
+ started := time.Now()
+ go func() {
+ defer close(done)
+ result, loadErr = chat(client, at, model, "ping")
+ }()
+
+ samples, err := probeUntil(client, at, "hol-probe", done)
+ elapsed := time.Since(started)
+ Expect(err).ToNot(HaveOccurred(), "a probe failed while %s was loading", model)
+ Expect(loadErr).ToNot(HaveOccurred())
+ Expect(result.status).To(Equal(http.StatusOK),
+ "loading %s failed, so nothing measured beside it is a measurement of contention: %s", model, result.body)
+ return elapsed, samples
+ }
+
+ measure := func(label string, through int, tinyModel, bulkModel string) {
+ at := c.FrontendURL(through)
+
+ baseline, err := probeN(client, at, "hol-probe", holProbeCount)
+ Expect(err).ToNot(HaveOccurred())
+
+ // The same cold load twice: once with nothing to transfer, once
+ // with the bulk artifact. The difference between the two windows is
+ // the transfer, which is what this spec is about; everything else
+ // about the two loads is identical.
+ tinyElapsed, underTiny := coldLoadUnderProbes(at, tinyModel)
+ bulkElapsed, underBulk := coldLoadUnderProbes(at, bulkModel)
+
+ transferWindow := bulkElapsed - tinyElapsed
+ Expect(transferWindow).To(BeNumerically(">=", minTransferWindow),
+ "%s: the %d MiB load took %s and the empty one took %s, so at most %s of the window was spent moving bytes; nothing below would be a measurement of a large message on the session",
+ label, bulkArtifactSize>>20, bulkElapsed, tinyElapsed, transferWindow)
+
+ // The second control, on the sample rather than on the window. A
+ // transfer nothing ran beside would report a clean bill from a
+ // window in which no probe was measured.
+ Expect(len(underBulk)).To(BeNumerically(">=", minOverlappingProbes),
+ "%s: only %d probes overlapped a transfer window of %s, which is too few to say anything about head-of-line blocking",
+ label, len(underBulk), transferWindow)
+
+ line := fmt.Sprintf("%s\n %s\n %s\n %s\n transfer window: %s of a %s load (%d MiB), empty load %s",
+ label,
+ summarise("baseline ", baseline),
+ summarise("under empty load ", underTiny),
+ summarise("under bulk load ", underBulk),
+ transferWindow.Round(time.Millisecond), bulkElapsed.Round(time.Millisecond),
+ bulkArtifactSize>>20, tinyElapsed.Round(time.Millisecond))
+ report = append(report, line)
+ GinkgoWriter.Println(line)
+
+ slowest := slowestOf(underBulk)
+ Expect(slowest).To(BeNumerically("<", transferWindow/holStallShare),
+ "%s: a probe waited %s of the %s in which bytes were moving, which is the shape of a session that stalled the probe until the transfer let go, not of one that interleaved them",
+ label, slowest, transferWindow)
+ control := slowestOf(underTiny)
+ Expect(control).To(BeNumerically(">", 0), "%s: the empty-load control produced no samples", label)
+ Expect(slowest).To(BeNumerically("<", holStallControlFactor*control),
+ "%s: the worst probe was %s while bytes were moving against %s under the same cold load with nothing to move, which is a stall rather than the contention a shared session costs",
+ label, slowest, control)
+ Expect(slowest).To(BeNumerically("<", holStallCeiling),
+ "%s: a probe waited %s while the bulk transfer held the session", label, slowest)
+ }
+
+ measure("direct (owner replica holds the tunnel)", owner, "hol-tiny-direct", "hol-bulk-direct")
+ measure("relayed (through the replica that does not own the tunnel)", nonOwner, "hol-tiny-relayed", "hol-bulk-relayed")
+
+ AddReportEntry("head-of-line blocking on one worker tunnel", strings.Join(report, "\n"))
+ })
+})
+
+// withBulkModel seeds a model whose artifact is large enough to keep the tunnel
+// busy while probes run.
+func withBulkModel(name string) func(*cluster.Options) {
+ return func(o *cluster.Options) {
+ if o.Models == nil {
+ o.Models = map[string]string{}
+ }
+ o.Models[name+".yaml"] = mockModelYAML(name)
+ o.Models[name+".bin"] = bulkArtifact()
+ }
+}
diff --git a/tests/e2e/distributed/control_workers_test.go b/tests/e2e/distributed/control_workers_test.go
new file mode 100644
index 000000000000..e8d5bd2bb658
--- /dev/null
+++ b/tests/e2e/distributed/control_workers_test.go
@@ -0,0 +1,190 @@
+package distributed_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ clustersvc "github.com/mudler/LocalAI/core/services/cluster"
+ "github.com/mudler/LocalAI/core/services/messaging"
+ "github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/workerctl"
+)
+
+// ControlWorkers is a fleet of fake workers serving the tunnelled control
+// plane, which is where the frontend's backend and model lifecycle verbs go
+// now that they have left the bus.
+//
+// It replaces the NATS SubscribeReply fakes these suites used to stand up. One
+// HTTP server answers for every node and reads which node a request is for off
+// the Host header, because that is what nodes.ControlClient puts there; a
+// client addressing the wrong node would be visible rather than silently
+// served.
+//
+// It is deliberately NOT a real worker: these suites are about what the
+// frontend does with a worker's answer. The real handlers are exercised against
+// the real client in core/services/worker.
+type ControlWorkers struct {
+ mu sync.Mutex
+ srv *httptest.Server
+ handlers map[string]func(nodeID string, body []byte) any
+}
+
+// controlHandlerKey is the (node, verb) a handler is registered for. AnyNode
+// registers one handler for every node, which is how a suite fakes a fleet
+// whose ids it does not know up front.
+const AnyNode = "*"
+
+func controlHandlerKey(nodeID, path string) string { return nodeID + " " + path }
+
+// NewControlWorkers starts the fleet and stops it when the spec ends.
+func NewControlWorkers() *ControlWorkers {
+ c := &ControlWorkers{handlers: map[string]func(string, []byte) any{}}
+ mux := http.NewServeMux()
+ mux.HandleFunc(workerctl.Prefix, c.serve)
+ c.srv = httptest.NewServer(mux)
+ DeferCleanup(c.srv.Close)
+ return c
+}
+
+// Client returns a control client that reaches this fleet.
+func (c *ControlWorkers) Client() *nodes.ControlClient {
+ addr := c.srv.Listener.Addr().String()
+ return nodes.NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", addr)
+ }
+ }, "")
+}
+
+// On registers what one node answers for one verb. Returning nil answers 204,
+// which is the shape the fire-and-forget verbs take.
+func (c *ControlWorkers) On(nodeID, path string, fn func(nodeID string, body []byte) any) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.handlers[controlHandlerKey(nodeID, path)] = fn
+}
+
+func (c *ControlWorkers) serve(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ Expect(err).ToNot(HaveOccurred())
+ nodeID := strings.TrimSuffix(r.Host, ".worker.invalid:80")
+
+ c.mu.Lock()
+ fn, ok := c.handlers[controlHandlerKey(nodeID, r.URL.Path)]
+ if !ok {
+ fn, ok = c.handlers[controlHandlerKey(AnyNode, r.URL.Path)]
+ }
+ c.mu.Unlock()
+
+ if !ok {
+ // Loud, never plausible: a verb no spec scripted must be a red spec
+ // rather than a worker that looks absent.
+ http.Error(w, "no control handler registered for "+r.URL.Path+" on "+nodeID, http.StatusInternalServerError)
+ return
+ }
+
+ reply := fn(nodeID, body)
+ streaming := r.URL.Path == workerctl.PathBackendInstall || r.URL.Path == workerctl.PathBackendUpgrade
+ if !streaming {
+ if reply == nil {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ Expect(json.NewEncoder(w).Encode(reply)).To(Succeed())
+ return
+ }
+
+ raw, err := json.Marshal(reply)
+ Expect(err).ToNot(HaveOccurred())
+ w.Header().Set("Content-Type", workerctl.ContentTypeStream)
+ w.WriteHeader(http.StatusOK)
+ // The reply line, and it is the last thing on the body by contract.
+ Expect(json.NewEncoder(w).Encode(workerctl.Envelope{Reply: raw})).To(Succeed())
+}
+
+// ServeBackendLifecycle registers the three verbs every routing spec needs from
+// a worker: install, the running-model list, and the backend list.
+//
+// The install reply NAMES the address of the backend process the fake worker
+// started, and that is the half these suites used to leave out. Since phase 2 a
+// worker advertises no address of its own, so this string is the only thing
+// that tells the frontend WHICH process on that worker a model was loaded into,
+// and installBackendOnNode refuses a success reply that omits it rather than
+// substituting anything. Every one of these suites runs its mock gRPC backend
+// on loopback and records where it listens in the node row it registers, so
+// that row is where this reads it back from. It is the spec's own bookkeeping
+// standing in for what a real worker reports about its own process; nothing in
+// production reads BackendNode.Address any more.
+func (c *ControlWorkers) ServeBackendLifecycle(registry *nodes.NodeRegistry) {
+ c.On(AnyNode, workerctl.PathBackendInstall, func(nodeID string, _ []byte) any {
+ node, err := registry.Get(context.Background(), nodeID)
+ if err != nil {
+ return messaging.BackendInstallReply{Success: false, Error: err.Error()}
+ }
+ return messaging.BackendInstallReply{Success: true, WorkerLocalAddress: node.Address}
+ })
+ c.On(AnyNode, workerctl.PathModelsRunning, func(string, []byte) any {
+ return messaging.ModelsRunningReply{}
+ })
+ c.On(AnyNode, workerctl.PathBackendList, func(string, []byte) any {
+ return messaging.BackendListReply{}
+ })
+}
+
+// workerBackendDialerFor stands in for the worker tunnel on the gRPC path.
+//
+// A frontend no longer dials a backend process: it opens a stream on the
+// worker's tunnel and names the process by its worker-local address. These
+// suites run the process on loopback, so a TCP dial to that address is the
+// stand-in, and NewTunnelClientFactory below is what makes the specs go through
+// a dialer at all rather than through the direct dial the default factory now
+// refuses.
+//
+// The refusal is translated, and that is the load-bearing half. A real worker
+// whose backend process has died answers the stream with
+// ErrStreamTargetUnavailable, which cluster.IsWorkerAnswer reads as the WORKER
+// speaking about its backend, and every reap guard in core/services/nodes acts
+// only on that. A bare ECONNREFUSED from net.Dialer carries no such thing and
+// reaches those guards as "no route", which reaps nothing. A double that
+// reported the raw syscall error could therefore never fail the way production
+// fails, and the stale-record spec in router_tracking_test.go would be
+// asserting against a transport that cannot produce the condition it is about.
+// That is not a hypothetical: replacing this translation with the raw error
+// reddens exactly that spec and nothing else.
+func workerBackendDialerFor(_ string) func(ctx context.Context, addr string) (net.Conn, error) {
+ return func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", clustersvc.ErrStreamTargetUnavailable, err)
+ }
+ return conn, nil
+ }
+}
+
+// tunnelBackendClients is the BackendClientFactory a SmartRouter needs in these
+// suites.
+//
+// It is NewTunnelClientFactory and not a bespoke double on purpose: the default
+// factory refuses every request now (see nodes.ErrNoWorkerDialer), so a spec
+// that omitted this would fail at the first inference with a boot-time
+// misconfiguration rather than testing anything, and a bespoke factory that
+// dialled directly would put back the bypass the phase removed.
+func tunnelBackendClients() nodes.BackendClientFactory {
+ GinkgoHelper()
+ factory, err := nodes.NewTunnelClientFactory("", workerBackendDialerFor)
+ Expect(err).ToNot(HaveOccurred())
+ return factory
+}
diff --git a/tests/e2e/distributed/dbname_test.go b/tests/e2e/distributed/dbname_test.go
new file mode 100644
index 000000000000..33305533d3da
--- /dev/null
+++ b/tests/e2e/distributed/dbname_test.go
@@ -0,0 +1,34 @@
+package distributed_test
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Test database naming", Label("Distributed"), func() {
+ Describe("sanitizeDBName", func() {
+ It("lowercases and replaces characters Postgres will not accept unquoted", func() {
+ Expect(sanitizeDBName("LocalAI-Test.Suite")).To(Equal("localai_test_suite"))
+ })
+
+ It("truncates to fit the 63-byte identifier limit with room for a suffix", func() {
+ long := ""
+ for i := 0; i < 100; i++ {
+ long += "a"
+ }
+ Expect(len(sanitizeDBName(long))).To(Equal(50),
+ "an over-long name must be truncated to exactly the 50-byte budget; <= 50 would also accept an empty name")
+ })
+
+ It("never produces an empty name", func() {
+ Expect(sanitizeDBName("---")).ToNot(BeEmpty())
+ })
+ })
+
+ Describe("replaceDBName", func() {
+ It("swaps the database in a testcontainers DSN and keeps the query string", func() {
+ dsn := "postgres://test:test@127.0.0.1:32768/localai_suite?sslmode=disable"
+ Expect(replaceDBName(dsn, "spec_7")).To(Equal("postgres://test:test@127.0.0.1:32768/spec_7?sslmode=disable"))
+ })
+ })
+})
diff --git a/tests/e2e/distributed/distributed_full_flow_test.go b/tests/e2e/distributed/distributed_full_flow_test.go
index ad7f2669aaf0..b6b2ceeca1c4 100644
--- a/tests/e2e/distributed/distributed_full_flow_test.go
+++ b/tests/e2e/distributed/distributed_full_flow_test.go
@@ -2,7 +2,6 @@ package distributed_test
import (
"context"
- "encoding/json"
"fmt"
"io"
"net"
@@ -11,8 +10,8 @@ import (
"path/filepath"
"time"
- "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -21,7 +20,6 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
- "github.com/nats-io/nats.go"
"google.golang.org/grpc"
pgdriver "gorm.io/driver/postgres"
@@ -225,13 +223,24 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
cancel()
})
- // newTestSmartRouter creates a SmartRouter with NATS wired up and a mock
- // backend.install handler that always replies success for all registered nodes.
+ // newTestSmartRouter creates a SmartRouter reaching a fleet of fake workers
+ // over the tunnelled control plane, with a backend.install handler that
+ // replies success for every registered node and names where that node's
+ // backend process listens.
+ //
+ // Both halves of the post-tunnel contract are here rather than in the
+ // specs, because they are the same two facts in every one of them: an
+ // install reply that names no process address is refused
+ // (installBackendOnNode), and a frontend with no worker dialer reaches no
+ // backend at all (nodes.ErrNoWorkerDialer).
newTestSmartRouter := func(reg *nodes.NodeRegistry, extraOpts ...nodes.SmartRouterOptions) *nodes.SmartRouter {
- unloader := nodes.NewRemoteUnloaderAdapter(reg, infra.NC, 3*time.Minute, 15*time.Minute)
+ workers := NewControlWorkers()
+ workers.ServeBackendLifecycle(reg)
+ unloader := nodes.NewRemoteUnloaderAdapter(reg, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
opts := nodes.SmartRouterOptions{
- Unloader: unloader,
+ Unloader: unloader,
+ ClientFactory: tunnelBackendClients(),
}
if len(extraOpts) > 0 {
o := extraOpts[0]
@@ -251,22 +260,6 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
router := nodes.NewSmartRouter(reg, opts)
- // Subscribe a mock backend.install handler that replies success for any node.
- // We use a wildcard-style approach: subscribe to all nodes' install subjects
- // by registering after each node. In practice, we rely on the test registering
- // nodes before calling Route, so we subscribe to a catch-all pattern.
- infra.NC.Conn().Subscribe("nodes.*.backend.install", func(msg *nats.Msg) {
- reply := messaging.BackendInstallReply{Success: true}
- data, _ := json.Marshal(reply)
- msg.Respond(data)
- })
- _, err := infra.NC.Conn().Subscribe("nodes.*.models.running", func(msg *nats.Msg) {
- data, _ := json.Marshal(messaging.ModelsRunningReply{})
- _ = msg.Respond(data)
- })
- Expect(err).NotTo(HaveOccurred())
- FlushNATS(infra.NC)
-
return router
}
// suppress unused warning in case some tests don't call it
@@ -338,8 +331,12 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
Expect(registry.Register(context.Background(), node2, true)).To(Succeed())
// Set both as having the model loaded
- Expect(registry.SetNodeModel(context.Background(), node1.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
- Expect(registry.SetNodeModel(context.Background(), node2.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
+ // The address is where that node's backend process listens, which is
+ // what a real install reply would have recorded on the row. A row
+ // carrying none names no process, so the router would re-install rather
+ // than route to it and this spec would be measuring the install path.
+ Expect(registry.SetNodeModel(context.Background(), node1.ID, "test-model", 0, "loaded", addr1, 0)).To(Succeed())
+ Expect(registry.SetNodeModel(context.Background(), node2.ID, "test-model", 0, "loaded", addr2, 0)).To(Succeed())
// Set node-1 with high in-flight (5), node-2 with low in-flight (1)
for range 5 {
@@ -385,30 +382,25 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
result.Release()
})
- It("should unload remote model via NATS", func() {
+ It("should unload a remote model over the worker's control plane", func() {
// Register a node with a loaded model
node := &nodes.BackendNode{Name: "gpu-unload", Address: "127.0.0.1:50099"}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node.ID, "old-model", 0, "loaded", "", 0)).To(Succeed())
- // Subscribe to NATS backend.stop for this node
- stopSubject := messaging.SubjectNodeBackendStop(node.ID)
+ // A worker serving backend.stop on its own control plane.
received := make(chan struct{}, 1)
- rawConn, err := nats.Connect(infra.NatsURL)
- Expect(err).ToNot(HaveOccurred())
- defer rawConn.Close()
-
- _, err = rawConn.Subscribe(stopSubject, func(msg *nats.Msg) {
+ workers := NewControlWorkers()
+ workers.On(node.ID, workerctl.PathBackendStop, func(string, []byte) any {
received <- struct{}{}
+ return nil
})
- Expect(err).ToNot(HaveOccurred())
// Create RemoteUnloaderAdapter and unload model
- unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
- err = unloader.UnloadRemoteModel("old-model")
- Expect(err).ToNot(HaveOccurred())
+ unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
+ Expect(unloader.UnloadRemoteModel("old-model")).To(Succeed())
- // Verify NATS event received
+ // The worker got the stop over its tunnel, not over the bus.
Eventually(received, 5*time.Second).Should(Receive())
// Verify model removed from registry
@@ -489,7 +481,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
// Create SmartRouter with the HTTPFileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
@@ -558,7 +550,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
// Create SmartRouter with FileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
@@ -616,7 +608,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
// Test AllocRemoteTemp + FetchRemote directly (the output retrieval path)
remoteTmpPath, err := stager.AllocRemoteTemp(ctx, node.ID)
@@ -662,7 +654,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
@@ -881,7 +873,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
// Create model files on the "frontend"
frontendModelsDir := GinkgoT().TempDir()
@@ -965,7 +957,7 @@ var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func()
return "", err
}
return n.HTTPAddress, nil
- }, "")
+ }, "", directWorkerDialerFor)
// Create model files: .onnx and .onnx.json in a temp "models" dir
frontendModelsDir := GinkgoT().TempDir()
diff --git a/tests/e2e/distributed/distributed_store_test.go b/tests/e2e/distributed/distributed_store_test.go
index 679a75a02b28..d35a7e0439a8 100644
--- a/tests/e2e/distributed/distributed_store_test.go
+++ b/tests/e2e/distributed/distributed_store_test.go
@@ -2,6 +2,7 @@ package distributed_test
import (
"context"
+ "net"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/pkg/model"
@@ -14,6 +15,25 @@ import (
"gorm.io/gorm/logger"
)
+// directBackendClients stands in for the worker tunnel in these specs.
+//
+// The store refuses to build a client for a remote model without a way to reach
+// the worker, which is the point: a model built with no client dials its raw
+// address on first use. These specs have no worker tunnel and no worker, so the
+// dial is a plain TCP one; production supplies the real dialer from
+// core/application.
+func directBackendClients() nodes.BackendClientFactory {
+ GinkgoHelper()
+ clients, err := nodes.NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
+ var d net.Dialer
+ return func(ctx context.Context, addr string) (net.Conn, error) {
+ return d.DialContext(ctx, "tcp", addr)
+ }
+ })
+ Expect(err).ToNot(HaveOccurred())
+ return clients
+}
+
var _ = Describe("DistributedModelStore", Label("Distributed"), func() {
var (
infra *TestInfra
@@ -36,7 +56,7 @@ var _ = Describe("DistributedModelStore", Label("Distributed"), func() {
Expect(err).ToNot(HaveOccurred())
localStore = model.NewInMemoryModelStore()
- dStore = nodes.NewDistributedModelStore(localStore, registry)
+ dStore = nodes.NewDistributedModelStore(localStore, registry, directBackendClients())
})
Context("Get", func() {
diff --git a/tests/e2e/distributed/file_staging_test.go b/tests/e2e/distributed/file_staging_test.go
index 55bd5663c6b4..a22a639c455b 100644
--- a/tests/e2e/distributed/file_staging_test.go
+++ b/tests/e2e/distributed/file_staging_test.go
@@ -7,9 +7,9 @@ import (
"path/filepath"
"github.com/mudler/LocalAI/core/config"
- "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/storage"
+ "github.com/mudler/LocalAI/core/services/workerctl"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -41,8 +41,8 @@ var _ = Describe("File Staging", Label("Distributed"), func() {
Expect(err).ToNot(HaveOccurred())
})
- Context("S3NATSFileStager", func() {
- It("should create S3NATSFileStager with valid config", func() {
+ Context("S3FileStager", func() {
+ It("should create S3FileStager with valid config", func() {
storeDir := filepath.Join(tmpDir, "objectstore")
cacheDir := filepath.Join(tmpDir, "cache")
@@ -53,7 +53,7 @@ var _ = Describe("File Staging", Label("Distributed"), func() {
Expect(err).ToNot(HaveOccurred())
Expect(fm.IsConfigured()).To(BeTrue())
- stager := nodes.NewS3NATSFileStager(fm, infra.NC)
+ stager := nodes.NewS3FileStager(fm, nodes.NewControlClient(directWorkerDialerFor, ""))
Expect(stager).ToNot(BeNil())
})
})
@@ -62,7 +62,7 @@ var _ = Describe("File Staging", Label("Distributed"), func() {
It("should create HTTPFileStager with httpAddrFor function", func() {
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
return "", fmt.Errorf("no such node: %s", nodeID)
- }, "")
+ }, "", directWorkerDialerFor)
Expect(stager).ToNot(BeNil())
// Should fail gracefully when node resolution fails
@@ -83,8 +83,8 @@ var _ = Describe("File Staging", Label("Distributed"), func() {
})
})
- Context("S3NATSFileStager with backend node simulation", func() {
- It("should coordinate file staging via NATS request-reply", func() {
+ Context("S3FileStager with backend node simulation", func() {
+ It("should coordinate file staging over the worker's control plane", func() {
storeDir := filepath.Join(tmpDir, "objectstore")
cacheDir := filepath.Join(tmpDir, "cache")
@@ -103,15 +103,14 @@ var _ = Describe("File Staging", Label("Distributed"), func() {
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
- // Verify NATS file staging subjects are correctly formed
- ensureSubj := messaging.SubjectNodeFilesEnsure(node.ID)
- Expect(ensureSubj).To(ContainSubstring("files.ensure"))
-
- stageSubj := messaging.SubjectNodeFilesStage(node.ID)
- Expect(stageSubj).To(ContainSubstring("files.stage"))
-
- tempSubj := messaging.SubjectNodeFilesTemp(node.ID)
- Expect(tempSubj).To(ContainSubstring("files.temp"))
+ // The staging verbs are HTTP routes on the worker's own control
+ // plane now, so what a registered node is addressed by is its
+ // tunnel host and the path, not a subject.
+ Expect(nodes.WorkerHTTPHost(node.ID, "")).To(ContainSubstring(node.ID))
+ Expect(workerctl.PathFilesEnsure).To(HavePrefix(workerctl.Prefix))
+ Expect(workerctl.PathFilesStage).To(HavePrefix(workerctl.Prefix))
+ Expect(workerctl.PathFilesTemp).To(HavePrefix(workerctl.Prefix))
+ Expect(workerctl.PathFilesListDir).To(HavePrefix(workerctl.Prefix))
})
})
diff --git a/tests/e2e/distributed/managers_test.go b/tests/e2e/distributed/managers_test.go
index b4f51ef957b2..83080f0d72b5 100644
--- a/tests/e2e/distributed/managers_test.go
+++ b/tests/e2e/distributed/managers_test.go
@@ -12,6 +12,7 @@ import (
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
@@ -136,30 +137,18 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
Expect(registry.SetNodeModel(context.Background(), node1.ID, "big-model", 0, "loaded", "", 0)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node2.ID, "big-model", 0, "loaded", "", 0)).To(Succeed())
- // Subscribe to model.delete on both node subjects, track receipt
+ // Both workers serve model.delete on their own control plane.
var deleteCount atomic.Int32
- sub1, err := infra.NC.SubscribeReply(messaging.SubjectNodeModelDelete(node1.ID), func(data []byte, reply func([]byte)) {
- var req messaging.ModelDeleteRequest
- json.Unmarshal(data, &req)
- Expect(req.ModelName).To(Equal("big-model"))
- deleteCount.Add(1)
- resp, _ := json.Marshal(messaging.ModelDeleteReply{Success: true})
- reply(resp)
- })
- Expect(err).ToNot(HaveOccurred())
- defer sub1.Unsubscribe()
-
- sub2, err := infra.NC.SubscribeReply(messaging.SubjectNodeModelDelete(node2.ID), func(data []byte, reply func([]byte)) {
- var req messaging.ModelDeleteRequest
- json.Unmarshal(data, &req)
- deleteCount.Add(1)
- resp, _ := json.Marshal(messaging.ModelDeleteReply{Success: true})
- reply(resp)
- })
- Expect(err).ToNot(HaveOccurred())
- defer sub2.Unsubscribe()
-
- FlushNATS(infra.NC)
+ workers := NewControlWorkers()
+ for _, id := range []string{node1.ID, node2.ID} {
+ workers.On(id, workerctl.PathModelDelete, func(_ string, data []byte) any {
+ var req messaging.ModelDeleteRequest
+ Expect(json.Unmarshal(data, &req)).To(Succeed())
+ Expect(req.ModelName).To(Equal("big-model"))
+ deleteCount.Add(1)
+ return messaging.ModelDeleteReply{Success: true}
+ })
+ }
// Create temp dir for local model files
tempDir, err := os.MkdirTemp("", "dist-model-test-*")
@@ -176,7 +165,7 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
appCfg := config.NewApplicationConfig()
appCfg.SystemState = ss
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
distMgr := nodes.NewDistributedModelManager(appCfg, ml, adapter)
err = distMgr.DeleteModel("big-model")
@@ -202,39 +191,24 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
Expect(registry.Register(context.Background(), node3, true)).To(Succeed())
Expect(registry.MarkUnhealthy(context.Background(), node3.ID)).To(Succeed())
- // Subscribe to backend.delete on all 3 nodes
+ // All 3 workers serve backend.delete on their control plane.
var deleteCount atomic.Int32
- sub1, err := infra.NC.SubscribeReply(messaging.SubjectNodeBackendDelete(node1.ID), func(data []byte, reply func([]byte)) {
- var req messaging.BackendDeleteRequest
- json.Unmarshal(data, &req)
- Expect(req.Backend).To(Equal("my-backend"))
- deleteCount.Add(1)
- resp, _ := json.Marshal(messaging.BackendDeleteReply{Success: true})
- reply(resp)
- })
- Expect(err).ToNot(HaveOccurred())
- defer sub1.Unsubscribe()
-
- sub2, err := infra.NC.SubscribeReply(messaging.SubjectNodeBackendDelete(node2.ID), func(data []byte, reply func([]byte)) {
- var req messaging.BackendDeleteRequest
- json.Unmarshal(data, &req)
- deleteCount.Add(1)
- resp, _ := json.Marshal(messaging.BackendDeleteReply{Success: true})
- reply(resp)
- })
- Expect(err).ToNot(HaveOccurred())
- defer sub2.Unsubscribe()
+ workers := NewControlWorkers()
+ for _, id := range []string{node1.ID, node2.ID} {
+ workers.On(id, workerctl.PathBackendDelete, func(_ string, data []byte) any {
+ var req messaging.BackendDeleteRequest
+ Expect(json.Unmarshal(data, &req)).To(Succeed())
+ Expect(req.Backend).To(Equal("my-backend"))
+ deleteCount.Add(1)
+ return messaging.BackendDeleteReply{Success: true}
+ })
+ }
var unhealthyReceived atomic.Int32
- sub3, err := infra.NC.SubscribeReply(messaging.SubjectNodeBackendDelete(node3.ID), func(data []byte, reply func([]byte)) {
+ workers.On(node3.ID, workerctl.PathBackendDelete, func(string, []byte) any {
unhealthyReceived.Add(1)
- resp, _ := json.Marshal(messaging.BackendDeleteReply{Success: true})
- reply(resp)
+ return messaging.BackendDeleteReply{Success: true}
})
- Expect(err).ToNot(HaveOccurred())
- defer sub3.Unsubscribe()
-
- FlushNATS(infra.NC)
// Create temp dir for local backend files
tempDir, err := os.MkdirTemp("", "dist-backend-test-*")
@@ -252,7 +226,7 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
appCfg := config.NewApplicationConfig()
appCfg.SystemState = ss
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
distMgr := nodes.NewDistributedBackendManager(appCfg, ml, adapter, registry, nil)
err = distMgr.DeleteBackend("my-backend")
@@ -275,18 +249,14 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
var deleteCount atomic.Int32
- sub1, err := infra.NC.SubscribeReply(messaging.SubjectNodeBackendDelete(node1.ID), func(data []byte, reply func([]byte)) {
+ workers := NewControlWorkers()
+ workers.On(node1.ID, workerctl.PathBackendDelete, func(_ string, data []byte) any {
var req messaging.BackendDeleteRequest
- json.Unmarshal(data, &req)
+ Expect(json.Unmarshal(data, &req)).To(Succeed())
Expect(req.Backend).To(Equal("remote-only-backend"))
deleteCount.Add(1)
- resp, _ := json.Marshal(messaging.BackendDeleteReply{Success: true})
- reply(resp)
+ return messaging.BackendDeleteReply{Success: true}
})
- Expect(err).ToNot(HaveOccurred())
- defer sub1.Unsubscribe()
-
- FlushNATS(infra.NC)
// Use a temp dir with NO local backend directory — simulates frontend node
tempDir, err := os.MkdirTemp("", "dist-backend-remote-only-*")
@@ -299,7 +269,7 @@ var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
appCfg := config.NewApplicationConfig()
appCfg.SystemState = ss
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
distMgr := nodes.NewDistributedBackendManager(appCfg, ml, adapter, registry, nil)
// Should NOT return an error even though the backend doesn't exist locally
diff --git a/tests/e2e/distributed/model_config_revision_test.go b/tests/e2e/distributed/model_config_revision_test.go
index 548a35eb4b93..9dde7f8b801f 100644
--- a/tests/e2e/distributed/model_config_revision_test.go
+++ b/tests/e2e/distributed/model_config_revision_test.go
@@ -33,7 +33,7 @@ func (s *revisionCleanupStopper) StopModelReplica(_ context.Context, nodeID stri
Matched: true,
Terminated: true,
ProcessKey: replica.ModelName,
- Address: replica.Address,
+ Address: replica.WorkerLocalAddress,
}, nil
}
diff --git a/tests/e2e/distributed/nats_jwt_helpers_test.go b/tests/e2e/distributed/nats_jwt_helpers_test.go
index 80060ef6a801..74f74355d098 100644
--- a/tests/e2e/distributed/nats_jwt_helpers_test.go
+++ b/tests/e2e/distributed/nats_jwt_helpers_test.go
@@ -153,4 +153,4 @@ func accountPublicKeyFromSeed(accountSeed string) string {
func nodeSubjectPrefix(nodeID string) string {
tok := strings.NewReplacer(".", "-", "*", "-", ">", "-", " ", "-", "\t", "-", "\n", "-").Replace(nodeID)
return "nodes." + tok
-}
\ No newline at end of file
+}
diff --git a/tests/e2e/distributed/nats_jwt_test.go b/tests/e2e/distributed/nats_jwt_test.go
index bf947e472ff2..b6b2343850fa 100644
--- a/tests/e2e/distributed/nats_jwt_test.go
+++ b/tests/e2e/distributed/nats_jwt_test.go
@@ -17,21 +17,46 @@ var _ = Describe("NATS JWT Auth", Label("Distributed", "NatsJWT"), func() {
infra = SetupJWTInfra()
})
- It("connects with a minted backend worker JWT and publishes on allowed subjects", func() {
- // Backend workers may publish under nodes..files.> (see pkg/natsauth permissions).
- subject := nodeSubjectPrefix(infra.NodeID) + ".files.in"
- Expect(infra.NC.Publish(subject, map[string]string{"path": "/tmp/model"})).To(Succeed())
+ It("connects with a minted backend worker JWT and publishes on its one remaining allowed subject", func() {
+ // A backend worker's whole grant is `_INBOX.>` now, on both sides.
+ // Every verb a frontend gives it, file staging included, is an HTTP
+ // route on its tunnel, and it no longer opens a bus connection at all;
+ // the JWT is minted and unused. See pkg/natsauth.WorkerPermissions.
+ Expect(infra.NC.Publish("_INBOX.probe", map[string]string{"path": "/tmp/model"})).To(Succeed())
Expect(infra.NC.Conn().FlushTimeout(2 * time.Second)).To(Succeed())
+ Expect(infra.NC.Conn().LastError()).ToNot(HaveOccurred())
Expect(infra.NC.Conn().IsConnected()).To(BeTrue())
})
- It("allows backend subscribe on the node prefix", func() {
+ It("denies a backend worker the file-staging subjects it no longer serves", func() {
+ // This spec used to assert the OPPOSITE, and kept passing after the
+ // grant was deleted. A NATS permission violation does not close the
+ // connection, so a spec that checks only FlushTimeout and IsConnected
+ // cannot tell an allowed publish from a denied one; LastError is what
+ // actually reads the server's verdict, which is why the sibling below
+ // has always used it.
+ subject := nodeSubjectPrefix(infra.NodeID) + ".files.stage"
+ Expect(infra.NC.Publish(subject, map[string]string{"path": "/tmp/model"})).To(Succeed())
+ Eventually(func() error {
+ _ = infra.NC.Conn().FlushTimeout(500 * time.Millisecond)
+ return infra.NC.Conn().LastError()
+ }, "3s", "50ms").Should(HaveOccurred())
+ })
+
+ It("denies backend subscribe on the node prefix it no longer listens to", func() {
+ // The node subtree was granted while a backend worker still held a
+ // connection with nothing under it subscribed. It does not hold one at
+ // all now, so the grant went too; asserting the denial is what would
+ // catch a subject quietly coming back to the bus.
wild := nodeSubjectPrefix(infra.NodeID) + ".>"
sub, err := infra.NC.Subscribe(wild, func(_ []byte) {})
- Expect(err).ToNot(HaveOccurred())
- defer func() { _ = sub.Unsubscribe() }()
- Expect(infra.NC.Conn().FlushTimeout(2 * time.Second)).To(Succeed())
- Expect(infra.NC.Conn().IsConnected()).To(BeTrue())
+ if err == nil {
+ defer func() { _ = sub.Unsubscribe() }()
+ Eventually(func() error {
+ _ = infra.NC.Conn().FlushTimeout(500 * time.Millisecond)
+ return infra.NC.Conn().LastError()
+ }, "3s", "50ms").Should(HaveOccurred())
+ }
})
It("rejects anonymous publish on the JWT-enabled server", func() {
diff --git a/tests/e2e/distributed/node_lifecycle_test.go b/tests/e2e/distributed/node_lifecycle_test.go
index 04b7342e794b..e3fdb1eefcb5 100644
--- a/tests/e2e/distributed/node_lifecycle_test.go
+++ b/tests/e2e/distributed/node_lifecycle_test.go
@@ -8,6 +8,7 @@ import (
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
+ "github.com/mudler/LocalAI/core/services/workerctl"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +18,7 @@ import (
"gorm.io/gorm/logger"
)
-var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), func() {
+var _ = Describe("Node Backend Lifecycle over the worker control plane", Label("Distributed"), func() {
var (
infra *TestInfra
db *gorm.DB
@@ -37,27 +38,23 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
Expect(err).ToNot(HaveOccurred())
})
- Context("NATS backend.install events", func() {
- It("should send backend.install request-reply to a specific node", func() {
+ Context("backend.install", func() {
+ It("should send backend.install to a specific node", func() {
node := &nodes.BackendNode{
Name: "gpu-node-1", Address: "h1:50051",
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
- // Simulate worker subscribing to backend.install and replying success
- infra.NC.SubscribeReply(messaging.SubjectNodeBackendInstall(node.ID), func(data []byte, reply func([]byte)) {
+ // The worker serves backend.install on its own control plane.
+ workers := NewControlWorkers()
+ workers.On(node.ID, workerctl.PathBackendInstall, func(_ string, data []byte) any {
var req messaging.BackendInstallRequest
- json.Unmarshal(data, &req)
+ Expect(json.Unmarshal(data, &req)).To(Succeed())
Expect(req.Backend).To(Equal("llama-cpp"))
-
- resp := messaging.BackendInstallReply{Success: true}
- respData, _ := json.Marshal(resp)
- reply(respData)
+ return messaging.BackendInstallReply{Success: true}
})
- FlushNATS(infra.NC)
-
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
installReply, err := adapter.InstallBackend(node.ID, "llama-cpp", "", "", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(installReply.Success).To(BeTrue())
@@ -69,16 +66,13 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
- // Simulate worker replying with error
- infra.NC.SubscribeReply(messaging.SubjectNodeBackendInstall(node.ID), func(data []byte, reply func([]byte)) {
- resp := messaging.BackendInstallReply{Success: false, Error: "backend not found"}
- respData, _ := json.Marshal(resp)
- reply(respData)
+ // The worker's own verdict: an answer, not a transport failure.
+ workers := NewControlWorkers()
+ workers.On(node.ID, workerctl.PathBackendInstall, func(string, []byte) any {
+ return messaging.BackendInstallReply{Success: false, Error: "backend not found"}
})
- FlushNATS(infra.NC)
-
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
installReply, err := adapter.InstallBackend(node.ID, "nonexistent", "", "", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(installReply.Success).To(BeFalse())
@@ -86,7 +80,7 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
})
})
- Context("NATS backend.stop events (model unload)", func() {
+ Context("backend.stop (model unload)", func() {
It("should send backend.stop to nodes hosting the model", func() {
node := &nodes.BackendNode{
Name: "gpu-node-2", Address: "h2:50051",
@@ -95,16 +89,14 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
Expect(registry.SetNodeModel(context.Background(), node.ID, "whisper-large", 0, "loaded", "", 0)).To(Succeed())
var stopReceived atomic.Int32
- sub, err := infra.NC.Subscribe(messaging.SubjectNodeBackendStop(node.ID), func(data []byte) {
+ workers := NewControlWorkers()
+ workers.On(node.ID, workerctl.PathBackendStop, func(string, []byte) any {
stopReceived.Add(1)
+ return nil
})
- Expect(err).ToNot(HaveOccurred())
- defer sub.Unsubscribe()
-
- FlushNATS(infra.NC)
// Frontend calls UnloadRemoteModel (triggered by UI "Stop" or WatchDog)
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
Expect(adapter.UnloadRemoteModel("whisper-large")).To(Succeed())
Eventually(func() int32 { return stopReceived.Load() }, "5s").Should(Equal(int32(1)))
@@ -123,18 +115,15 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
registry.SetNodeModel(context.Background(), node2.ID, "shared-model", 0, "loaded", "", 0)
var count atomic.Int32
- sub1, _ := infra.NC.Subscribe(messaging.SubjectNodeBackendStop(node1.ID), func(data []byte) {
- count.Add(1)
- })
- sub2, _ := infra.NC.Subscribe(messaging.SubjectNodeBackendStop(node2.ID), func(data []byte) {
- count.Add(1)
- })
- defer sub1.Unsubscribe()
- defer sub2.Unsubscribe()
-
- FlushNATS(infra.NC)
+ workers := NewControlWorkers()
+ for _, id := range []string{node1.ID, node2.ID} {
+ workers.On(id, workerctl.PathBackendStop, func(string, []byte) any {
+ count.Add(1)
+ return nil
+ })
+ }
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
adapter.UnloadRemoteModel("shared-model")
Eventually(func() int32 { return count.Load() }, "5s").Should(Equal(int32(2)))
@@ -150,49 +139,57 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
// The same contract is pinned at unit level by "with no nodes
// returns nil" in core/services/nodes/unloader_test.go; keep them
// in step.
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, NewControlWorkers().Client(), 3*time.Minute, 15*time.Minute)
Expect(adapter.UnloadRemoteModel("nonexistent-model")).To(Succeed())
})
})
- Context("NATS node stop events (full shutdown)", func() {
- It("should publish stop event to a node", func() {
+ Context("node.stop (full shutdown)", func() {
+ It("should ask the node to shut down over its control plane", func() {
node := &nodes.BackendNode{
Name: "stop-me", Address: "h3:50051",
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
var stopped atomic.Int32
- sub, err := infra.NC.Subscribe(messaging.SubjectNodeStop(node.ID), func(data []byte) {
+ workers := NewControlWorkers()
+ workers.On(node.ID, workerctl.PathNodeStop, func(string, []byte) any {
stopped.Add(1)
+ return nil
})
- Expect(err).ToNot(HaveOccurred())
- defer sub.Unsubscribe()
- FlushNATS(infra.NC)
-
- adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
Expect(adapter.StopNode(node.ID)).To(Succeed())
Eventually(func() int32 { return stopped.Load() }, "5s").Should(Equal(int32(1)))
})
})
- Context("NATS subject naming", func() {
- It("should generate correct backend lifecycle subjects", func() {
- Expect(messaging.SubjectNodeBackendInstall("node-abc")).To(Equal("nodes.node-abc.backend.install"))
+ Context("wire naming", func() {
+ // Written out BY HAND, and not derived from the constants: a frontend
+ // and a worker built from different commits reach each other over these
+ // literals, and a renamed path is a 404 that looks exactly like a
+ // broken tunnel.
+ It("should name the backend lifecycle control verbs", func() {
+ Expect(workerctl.PathBackendInstall).To(Equal("/v1/control/backend/install"))
+ Expect(workerctl.PathBackendStop).To(Equal("/v1/control/backend/stop"))
+ Expect(workerctl.PathNodeStop).To(Equal("/v1/control/node/stop"))
+ })
+
+ // The one node subject left, and it is addressed only to AGENT workers:
+ // they hold no tunnel and subscribe to it to drop cached MCP sessions.
+ It("should keep the agent worker's backend.stop subject", func() {
Expect(messaging.SubjectNodeBackendStop("node-abc")).To(Equal("nodes.node-abc.backend.stop"))
- Expect(messaging.SubjectNodeStop("node-abc")).To(Equal("nodes.node-abc.stop"))
})
})
- // Design note: LoadModel is a direct gRPC call to node.Address, NOT a NATS event.
- // NATS is used for backend.install (install + start process) and backend.stop.
- // The SmartRouter calls grpc.NewClient(node.Address).LoadModel() directly.
+ // Design note: LoadModel is a gRPC call through the worker's tunnel, not a
+ // control verb. The control plane installs and stops the process; the model
+ // is loaded into it over the `grpc` stream tag.
//
// Flow:
- // 1. NATS backend.install → worker installs backend + starts gRPC process
- // 2. SmartRouter.Route() → gRPC LoadModel(node.Address) directly
- // 3. [inference via gRPC]
- // 4. NATS backend.stop → worker stops gRPC process
+ // 1. backend.install → worker installs backend + starts gRPC process
+ // 2. SmartRouter.Route() → LoadModel over the worker's tunnel
+ // 3. [inference over the tunnel]
+ // 4. backend.stop → worker stops gRPC process
})
diff --git a/tests/e2e/distributed/prefix_cache_routing_test.go b/tests/e2e/distributed/prefix_cache_routing_test.go
index 9b1e3c117718..e899160ea2fd 100644
--- a/tests/e2e/distributed/prefix_cache_routing_test.go
+++ b/tests/e2e/distributed/prefix_cache_routing_test.go
@@ -51,6 +51,10 @@ func (f *prefixStubClientFactory) NewClient(_ string, _ bool) grpcPkg.Backend {
return f.client
}
+func (f *prefixStubClientFactory) NewClientForNode(_, _ string, _ bool) (grpcPkg.Backend, error) {
+ return f.client, nil
+}
+
var _ = Describe("Prefix-cache aware routing", Label("Distributed"), func() {
const model = "model"
diff --git a/tests/e2e/distributed/router_tracking_test.go b/tests/e2e/distributed/router_tracking_test.go
index 75895a372d96..a3f92c9ef52c 100644
--- a/tests/e2e/distributed/router_tracking_test.go
+++ b/tests/e2e/distributed/router_tracking_test.go
@@ -2,10 +2,8 @@ package distributed_test
import (
"context"
- "encoding/json"
"time"
- "github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -15,8 +13,6 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
- "github.com/nats-io/nats.go"
-
pgdriver "gorm.io/driver/postgres"
gormDB "gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -60,34 +56,35 @@ var _ = Describe("SmartRouter trackingKey", Label("Distributed"), func() {
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
- // Mock backend.install handler — always replies success
- infra.NC.Conn().Subscribe("nodes.*.backend.install", func(msg *nats.Msg) {
- reply := messaging.BackendInstallReply{Success: true}
- data, _ := json.Marshal(reply)
- msg.Respond(data)
- })
- _, err = infra.NC.Conn().Subscribe("nodes.*.models.running", func(msg *nats.Msg) {
- data, _ := json.Marshal(messaging.ModelsRunningReply{})
- _ = msg.Respond(data)
- })
- Expect(err).NotTo(HaveOccurred())
- FlushNATS(infra.NC)
+ // Mock control plane. The install reply names where the backend process
+ // listens on that worker, which is what the frontend routes to now that
+ // a worker advertises no address of its own.
+ workers := NewControlWorkers()
+ workers.ServeBackendLifecycle(registry)
// Start a mock gRPC backend using the same helper as full flow tests
llm := &trackingTestLLM{}
grpcAddr, grpcCleanup, err = startTestGRPCServer(grpcPkg.AIModel(llm))
Expect(err).ToNot(HaveOccurred())
- // Register a node pointing to the mock backend
+ // Register a node whose backend process is the mock server above. The
+ // address is the spec's own record of where that process listens; the
+ // fake worker reports it back on install, and nothing in production
+ // reads this column any more.
node := &nodes.BackendNode{
Name: "tracking-node", Address: grpcAddr,
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
nodeID = node.ID
- unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
+ unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
router = nodes.NewSmartRouter(registry, nodes.SmartRouterOptions{
Unloader: unloader,
+ // Without a worker dialer the default factory refuses every
+ // request (nodes.ErrNoWorkerDialer), which is a boot-time
+ // misconfiguration rather than a routing outcome any of these
+ // specs is about.
+ ClientFactory: tunnelBackendClients(),
})
})
diff --git a/tests/e2e/distributed/testhelpers_test.go b/tests/e2e/distributed/testhelpers_test.go
index 68cf537e30bd..17ee72ce2e89 100644
--- a/tests/e2e/distributed/testhelpers_test.go
+++ b/tests/e2e/distributed/testhelpers_test.go
@@ -2,6 +2,10 @@ package distributed_test
import (
"context"
+ "fmt"
+ "net/url"
+ "strings"
+ "sync/atomic"
"time"
"github.com/mudler/LocalAI/core/services/messaging"
@@ -13,9 +17,17 @@ import (
tcnats "github.com/testcontainers/testcontainers-go/modules/nats"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
)
// TestInfra holds shared test containers and connection strings.
+//
+// PGContainer and NATSContainer are the SUITE-WIDE containers, shared by every
+// spec. Never call Terminate or Stop on them from a spec: it ends the run for
+// everything after it. They are exposed only because nats_jwt_helpers_test.go
+// builds its own TestInfra around a dedicated NATS container.
type TestInfra struct {
Ctx context.Context
PGContainer *tcpostgres.PostgresContainer
@@ -25,71 +37,188 @@ type TestInfra struct {
NC *messaging.Client
}
-// SetupInfra starts PostgreSQL and NATS containers and connects a messaging client.
-// Call in BeforeEach. Use DeferCleanup or call Teardown in AfterEach.
-func SetupInfra(dbName string) *TestInfra {
- GinkgoHelper()
+// Containers are suite-scoped, not spec-scoped. Starting a Postgres (~10s) and a
+// NATS (~3.5s) per spec cost roughly 48 minutes of pure startup across the 213
+// specs behind SetupInfra, which is why this suite was never wired into CI.
+// Isolation now comes from a database per spec (~67ms), which is what the dbName
+// argument was always describing.
+//
+// Plain BeforeSuite rather than SynchronizedBeforeSuite is deliberate: under
+// `ginkgo -p` each process gets its own container pair, which keeps NATS subjects
+// isolated per process. A single shared NATS across parallel processes would let
+// specs on different processes see each other's messages on the same subject.
+var (
+ suitePG *tcpostgres.PostgresContainer
+ suiteNATS *tcnats.NATSContainer
+ suitePGDSN string
+ suiteNatsURL string
+ dbCounter atomic.Int64
+)
- infra := &TestInfra{Ctx: context.Background()}
+var _ = BeforeSuite(func() {
+ ctx := context.Background()
var err error
- // Start PostgreSQL container
- infra.PGContainer, err = tcpostgres.Run(infra.Ctx, "postgres:16-alpine",
- tcpostgres.WithDatabase(dbName),
+ suitePG, err = tcpostgres.Run(ctx, "postgres:16-alpine",
+ tcpostgres.WithDatabase("localai_suite"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
- WithStartupTimeout(30*time.Second),
+ WithStartupTimeout(90*time.Second),
),
)
Expect(err).ToNot(HaveOccurred())
- infra.PGURL, err = infra.PGContainer.ConnectionString(infra.Ctx, "sslmode=disable")
+ suitePGDSN, err = suitePG.ConnectionString(ctx, "sslmode=disable")
Expect(err).ToNot(HaveOccurred())
- // Start NATS container
- infra.NATSContainer, err = tcnats.Run(infra.Ctx, "nats:2-alpine")
+ suiteNATS, err = tcnats.Run(ctx, "nats:2-alpine")
Expect(err).ToNot(HaveOccurred())
- infra.NatsURL, err = infra.NATSContainer.ConnectionString(infra.Ctx)
+ suiteNatsURL, err = suiteNATS.ConnectionString(ctx)
Expect(err).ToNot(HaveOccurred())
+})
+
+var _ = AfterSuite(func() {
+ ctx := context.Background()
+ if suitePG != nil {
+ _ = suitePG.Terminate(ctx)
+ }
+ if suiteNATS != nil {
+ _ = suiteNATS.Terminate(ctx)
+ }
+})
+
+// sanitizeDBName maps a spec-supplied label onto a legal unquoted Postgres
+// identifier, leaving headroom for the uniqueness suffix appended by SetupInfra.
+func sanitizeDBName(name string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(name) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
+ b.WriteRune(r)
+ default:
+ b.WriteRune('_')
+ }
+ }
+ out := strings.Trim(b.String(), "_")
+ if out == "" {
+ out = "spec"
+ }
+ // Postgres identifiers cap at 63 bytes; reserve the rest for "_".
+ if len(out) > 50 {
+ out = out[:50]
+ }
+ return out
+}
- // Connect messaging client
- infra.NC, err = messaging.New(infra.NatsURL)
+// replaceDBName swaps the database component of a DSN, preserving credentials,
+// host, port and query parameters.
+func replaceDBName(dsn, name string) string {
+ GinkgoHelper()
+ u, err := url.Parse(dsn)
Expect(err).ToNot(HaveOccurred())
+ u.Path = "/" + name
+ return u.String()
+}
+
+// tryAdminDB opens a short-lived connection to the suite's maintenance
+// database. Cleanup paths use this rather than adminDB: once connections are
+// scarce, a fatal assertion here would convert one Postgres hiccup into a
+// suite-wide cascade that buries the original failure.
+//
+// CREATE/DROP DATABASE cannot run inside a transaction or against the target
+// database itself, so every call gets its own connection and closes it.
+func tryAdminDB() (*gorm.DB, error) {
+ db, err := gorm.Open(postgres.Open(suitePGDSN), &gorm.Config{Logger: gormlogger.Discard})
+ if err != nil {
+ return nil, fmt.Errorf("connecting to the suite maintenance database: %w", err)
+ }
+ return db, nil
+}
+
+func adminDB() *gorm.DB {
+ GinkgoHelper()
+ db, err := tryAdminDB()
+ Expect(err).ToNot(HaveOccurred())
+ return db
+}
+
+func closeDB(db *gorm.DB) {
+ if db == nil {
+ return
+ }
+ if sqlDB, err := db.DB(); err == nil {
+ _ = sqlDB.Close()
+ }
+}
- // Register cleanup in LIFO order
+// SetupInfra provisions a dedicated database on the suite-scoped Postgres and
+// returns a client connected to the suite-scoped NATS. Call in BeforeEach;
+// cleanup is registered with DeferCleanup.
+func SetupInfra(dbName string) *TestInfra {
+ GinkgoHelper()
+ Expect(suitePG).ToNot(BeNil(), "SetupInfra called before BeforeSuite started the shared containers")
+
+ infra := &TestInfra{
+ Ctx: context.Background(),
+ PGContainer: suitePG,
+ NATSContainer: suiteNATS,
+ NatsURL: suiteNatsURL,
+ }
+
+ db := fmt.Sprintf("%s_%d", sanitizeDBName(dbName), dbCounter.Add(1))
+
+ // Scoped so a failed CREATE cannot leak the pool: the assertion panics, and a
+ // leaked pgx pool per failing spec exhausts the server's connection limit.
+ func() {
+ admin := adminDB()
+ defer closeDB(admin)
+ Expect(admin.Exec(fmt.Sprintf("CREATE DATABASE %q", db)).Error).To(Succeed())
+ }()
+
+ // Registered before anything else can fail: a NATS connect error below would
+ // otherwise leave the database behind for the rest of the suite.
DeferCleanup(func() {
if infra.NC != nil {
infra.NC.Close()
}
- if infra.PGContainer != nil {
- infra.PGContainer.Terminate(context.Background())
+ drop, err := tryAdminDB()
+ if err != nil {
+ AddReportEntry("drop database skipped", fmt.Sprintf("%s: %v", db, err))
+ return
}
- if infra.NATSContainer != nil {
- infra.NATSContainer.Terminate(context.Background())
+ defer closeDB(drop)
+ // FORCE terminates any connection the spec left open (Postgres 13+).
+ if err := drop.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %q WITH (FORCE)", db)).Error; err != nil {
+ AddReportEntry("drop database failed", fmt.Sprintf("%s: %v", db, err))
}
})
+ infra.PGURL = replaceDBName(suitePGDSN, db)
+
+ var err error
+ infra.NC, err = messaging.New(infra.NatsURL)
+ Expect(err).ToNot(HaveOccurred())
+
return infra
}
-// SetupNATSOnly starts only a NATS container and connects a messaging client.
-// Useful for tests that don't need PostgreSQL.
+// SetupNATSOnly returns a client on the suite-scoped NATS for specs that need no
+// database.
func SetupNATSOnly() *TestInfra {
GinkgoHelper()
+ Expect(suiteNATS).ToNot(BeNil(), "SetupNATSOnly called before BeforeSuite started the shared containers")
- infra := &TestInfra{Ctx: context.Background()}
- var err error
-
- infra.NATSContainer, err = tcnats.Run(infra.Ctx, "nats:2-alpine")
- Expect(err).ToNot(HaveOccurred())
-
- infra.NatsURL, err = infra.NATSContainer.ConnectionString(infra.Ctx)
- Expect(err).ToNot(HaveOccurred())
+ infra := &TestInfra{
+ Ctx: context.Background(),
+ NATSContainer: suiteNATS,
+ NatsURL: suiteNatsURL,
+ }
+ var err error
infra.NC, err = messaging.New(infra.NatsURL)
Expect(err).ToNot(HaveOccurred())
@@ -97,16 +226,12 @@ func SetupNATSOnly() *TestInfra {
if infra.NC != nil {
infra.NC.Close()
}
- if infra.NATSContainer != nil {
- infra.NATSContainer.Terminate(context.Background())
- }
})
return infra
}
// FlushNATS ensures all subscriptions are registered server-side before publishing.
-// Replaces time.Sleep(100ms) after Subscribe calls.
func FlushNATS(nc *messaging.Client) {
GinkgoHelper()
Expect(nc.Conn().Flush()).To(Succeed())