Skip to content

feat(ee): LAN compute cluster - #191

Open
ganisback wants to merge 30 commits into
mainfrom
feat/ee-lan-cluster
Open

ganisback wants to merge 30 commits into
mainfrom
feat/ee-lan-cluster

Conversation

@ganisback

Copy link
Copy Markdown
Collaborator

Closes #172.

Combines the compute boxes on a LAN into one pool, so a request that arrives
at any machine is served by whichever member can answer it soonest. Their IP
addresses change on every reboot, so nothing in the design depends on an
address: members are identified by a UUID pinned to a self-signed certificate,
found again over mDNS, and their addresses are relearned on every poll.

What it does

Forms itself. Every machine installed with the same
CSGHUB_LITE_CLUSTER_SECRET founds or joins the same cluster with no create
or join step: the cluster UUID is derived from the secret, and the secret
itself never goes on the wire. Manual pairing by join token or by an 8-digit
admission code still works for anyone who wants it.

Stays out of the way on one machine. Without that variable, and until an
operator asks for clustering, there is no listener, no multicast and no
polling goroutine. A single machine pays nothing for a feature it is not
using.

Routes on predicted completion time, not round-robin: load time plus
queue plus prompt plus decode, with penalties for thermal throttling, power
caps, CPU contention, RAM pressure, disk activity and foreign GPU use. A
conversation sticks to the node that answered it, and a node that fails is
taken out and retried with backoff.

Copies models between members over the cluster link rather than
re-downloading from the internet, verifying a SHA-256 the sender computes
while streaming. Derived files such as a converted GGUF follow only over a
link fast enough to be worth it.

Speaks the existing APIs. Chat, embeddings, Anthropic messages,
transcription and speech all route through the engine getters that were
already there rather than a second router. A request opts in with
source: "cluster", pins a machine with source: "node:<uuid>", or names
nothing and is routed only when this node cannot serve it.

Licensing

The feature and the dashboard are open to everyone. Only the node count is
gated: quota.lite.max_cluster_nodes is 2 without a licence, unlimited with
one. The cluster code lives in ee/ under the Enterprise licence; the seam
internal/server exposes to it is a single Host interface.

Measured on two machines

An M5 and an M4 on the same 5 GHz network, entry node M5. Single machine is
source: "local", two machines is balanced mode.

Text generation, Qwen3.5-2B, 128 tokens per request:

concurrency 1 machine 2 machines speedup TTFT 1 TTFT 2
1 4.30s 5.37s 0.80x 0.09s 0.41s
2 8.62s 5.23s 1.65x 2.29s 0.29s
4 16.99s 10.36s 1.64x 6.48s 2.66s
8 34.43s 20.72s 1.66x 15.13s 7.51s

Embedding, Qwen3-Embedding-0.6B, 32 inputs per request:

concurrency 1 machine 2 machines speedup
1 0.67s 0.87s 0.77x
2 1.27s 1.51s 0.84x
4 2.51s 2.10s 1.19x
8 5.45s 3.01s 1.81x
16 10.41s 5.73s 1.82x
32 20.68s 11.10s 1.86x

One machine's throughput is flat whatever the concurrency, so extra requests
only queue; the second machine roughly doubles it and halves time to first
token. Below two concurrent requests clustering costs 16-23% because there is
nothing to spread and a network hop to pay, which is why local_first is the
default and balanced is opt-in.

Peer model copy measured 1.1 GB in 37 seconds. Failover was exercised by
stopping a member: cluster-sourced requests fell back to the surviving node
and a request pinned to the stopped one returned a clear 503.

Review pass

The last six commits are an architecture and duplication review of the
preceding work, with the three real defects it found:

  • A routed embeddings request placed on the local node was served by a chat
    engine, because the kind of engine never travelled with the request. A
    llama-server started without --embeddings cannot answer it.
  • An AI app could not be bound to the cluster at all: the provider route
    vocabulary did not know cluster or node:<uuid> and rejected them.
  • Every forwarded inference request built a fresh HTTP client and paid a
    full TLS handshake, then abandoned the transport. Members now keep one
    pooled client each, which measured 0.42s to 0.05s on the second request.

The rest removes duplication: one owner for the request source vocabulary
instead of four copies of the precedence, one dispatch helper for the routed
speech engines instead of four copies of the same skeleton, one Chat
instead of two, and one local-status cache instead of two with a stale one
the invalidation could not reach. A 429 from a member is now a cooldown that
honours Retry-After rather than an immediate retry.

Known gaps

Deliberately not addressed here, each needing a shared layer across the
Apache and Enterprise packages:

  • The cluster router and the provider pool router are still two
    implementations of dispatch, failover and conversation affinity.
  • Manager carries 116 methods across five files.
  • About 1500 lines under internal/server are cluster logic; roughly a third
    of that must stay there because it reads private server state, the rest
    belongs in ee/.
  • ee/LICENSE still needs a clause permitting production use within the
    community node quota.

Testing

go test -race ./... passes with no data races, make lint passes, 83 web
tests pass. The cluster package has 39 cases and the server has 8 cluster
integration cases, plus the two-machine runs above.

Generated with AI

Co-Authored-By: csglite xzhgan@gmail.com

ganisback and others added 22 commits September 19, 2026 19:08
Adds the local-network compute cluster (issue #172). CSGLite nodes on one
network discover each other over mDNS, pair with a join token or an
admission code, and route inference to the member that holds the model.
Nodes are identified by a persisted UUID and self-signed certificate pinned
at pairing, so an IP change after a reboot needs no action. Node-to-node
traffic runs over a separate mTLS listener (:11438); forwarded requests are
executed locally and never routed again.

- ee/cluster: identity, membership store, mDNS/in-memory discovery,
  directory with health state machine, estimated-completion-time scheduler
  with explain output, session affinity, node/cluster engines with
  failover, management and peer HTTP handlers.
- internal/server: host adapter (status, hardware, forwarded inference),
  getChatEngine/getEmbeddingEngine hooks, /v1/models merge, node response
  headers, /api/cluster/* routes, OpenAPI.
- internal/license: feature.lite.lan_cluster (ungated) and the gated
  quota.lite.max_cluster_nodes (Community edition: 2 nodes).
- CLI: csghub-lite cluster {status,create,join,leave,token,code,nodes,
  discovered,invite,remove,models,sync,explain,drain,activate,maintenance}.
- Web: dashboard cluster node cards and the cluster management page.
- Docs: design (docs/guides/lan-cluster-design.md), CLI, env vars, Docker.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…e headers

- Re-probe unpaired nodes every 20 seconds: mDNS reports an instance once,
  so a node that restarted or joined another cluster went stale or aged out
  of the discovered list and a token join could not find it.
- Fold embedding-mode engines into the node status so a model serving
  embeddings shows as loaded to the scheduler and the dashboard.
- Copy X-CSGLite-Node headers on embeddings and Anthropic responses.
- Route to the cluster whenever a peer holds the model, even while that
  peer is draining, so the caller gets "no cluster node can serve" instead
  of "model not found locally".
- Publish a unique .local host name per node (the DNS packer needs the
  canonical trailing dot) and give IP-named hosts a distinct default name.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Machines installed with the same CSGHUB_LITE_CLUSTER_SECRET now find each
other and form one cluster with no create or join step. The cluster UUID
and join token are derived from the secret (UUIDv5 and HMAC), so every node
agrees on them while the secret itself never touches disk or the network.
The first node up founds the cluster after a short grace period; later nodes
join whichever member they discover; two nodes that found in parallel merge
through the same join handshake. An explicit leave pauses automatic
formation until the node is joined or created into a cluster again.

- config: new `cluster` section (secret, name) with env override; the
  installers persist CSGHUB_LITE_CLUSTER_SECRET / _NAME via
  `csghub-lite config set cluster_secret`.
- CLI: `config set|get|unset cluster_secret|cluster_name`.
- API/UI: `auto_form` fields on the cluster view and a notice on the page.
- Fix a data race on the node display name (rename vs. status readers) and
  keep former members discoverable after leaving so a re-join needs no new
  multicast announcement.
- Docs: env vars, installation, cluster and config CLI, design section 6.1.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…anted

A node with no cluster secret, no join token and no membership now opens no
peer listener, joins no multicast group, sends no mDNS queries and runs no
polling or gossip goroutines. Networking is switched on by provisioning
(secret or token), by an existing membership, by a previous explicit enable,
or by the operator's create / join / invite / admission-code actions and
POST /api/cluster/enable. A node that leaves on purpose goes dormant again;
POST /api/cluster/disable and `csghub-lite cluster disable` do so explicitly.
The cluster view and page show the state and offer to turn it on.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
The secret is the operator's choice; anything from four characters is
accepted and a warning is logged below twelve, instead of refusing outright.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
- Dashboard no longer shows cluster nodes; the cluster page is the one view.
- Nav item and title renamed to "Cluster" / "集群".
- Overview table: fewer columns, no wrapping (node name truncates, in-flight
  folded into status, short GB formatting, short version), IP-style host
  names hidden, node cap shown as "2 / 2" with a one-line hint.
- Generated node names shortened to "node-<6 hex>"; earlier long names are
  migrated on start.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Speech recognition and synthesis resolve their engine by model and source
through getASREngine / getTTSEngine, shaped like getChatEngine: an explicit
cluster or node source, the cluster for models this node lacks (or every
model in balanced mode), the local runtime, then a peer when the local load
fails. The cluster-backed engines rebuild the upload or JSON body, forward
it through the shared scheduler and failover path, and report the executing
node in the response headers. Handlers keep their shape; a failure on a
routed engine never evicts a local worker. Node status now lists the Python
workers (recognition, synthesis, image) as single-slot engines.

A peer that answers "not a forwardable inference endpoint" is reported as an
outdated node (502) rather than a missing model.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…leeping

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
When the scheduler chose the local node, the dispatch reservation was
released before the local work ran and the local Python workers did not
count plain requests, so every concurrent request looked like it hit an idle
machine and balanced mode never moved work elsewhere. RouteRaw now hands the
caller a LocalChoice whose Release ends the reservation after the local call,
the local candidate carries its reservations into ranking, and the audio
handlers retain the worker for the duration of a request.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
"modelscope/Qwen/Qwen3.5-2B" now becomes a pull of Qwen/Qwen3.5-2B from
ModelScope on the target node instead of an invalid OpenCSG repository name.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…urce

A pull job on a cluster member first looks for an online member that holds
the model completely and copies its files over the mTLS cluster channel,
verifying size and a SHA-256 trailer, then installs them in the normal model
directory layout with the peer's manifest. Only when no member has the model
does the job download from the model source. Peers serve
/cluster/v1/model-bundle and /cluster/v1/model-file to members only, with
path checks confined to the model directory.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…iles follow only over a fast link

A model directory holds the files the download produced plus artifacts the
node derived from them (a GGUF converted from safetensors). The peer copy now
transfers the manifest-listed files, installs the model, and only then copies
derived files in the background, and only when the measured link exceeds
20 MB/s: on a slow link regenerating a conversion beats copying gigabytes.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
A cluster model id is a public id: an OpenCSG model is advertised under its
short name, which is not a repository, so syncing it created a pull job for
an invalid model id. Node status now carries the repository and artifact
source alongside each model, the sync endpoint uses whatever the holding
node reports, and PullSpec resolves a local model before parsing the id.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
A poll that did not report back left the member marked as being polled
forever: no further poll was scheduled, so its health, address and last error
stayed frozen even once it was plainly reachable again. Observed after both
machines changed IP at once, where one node kept reporting the other as down
at its old address while gossip from that very node was arriving.

- A poll in flight longer than 45s is abandoned and polling resumes.
- An authenticated request from a member, or an answer it streamed back, is
  proof of life: it now heals a member written off as down and schedules an
  immediate poll, so a node whose address changed recovers from the first
  packet it sends rather than waiting for discovery.
- One poll is bounded across every candidate address, so it always reports
  back before the watchdog fires.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
A member with one stale address and one good one hid the error that mattered
behind the stale address's "no route to host", which made a real connection
failure impossible to tell apart from an outdated address.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
A routed request went to Host.LocalEngine with no notion of which kind
of engine it needed, and the host always answered with the chat engine.
An embeddings request that the scheduler placed on the local node was
therefore served by a llama-server started without --embeddings, while
the same request forwarded to a peer was correct because the peer's own
handler picked the embedding loader. The asymmetry was the missing
parameter.

EngineOptions now carries an EngineKind, zero value chat, and the host
dispatches on it. Manager.ChatEngine is renamed RoutedEngine: it has
served embeddings since the embedding path started routing, and the old
name said otherwise.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
providerRouteIDForSource knew about local, cloud, pools and providers
but not about the cluster, so it fell through to "unsupported model
source". Every AI app integration scopes its base URL through that
function, which meant an app could be pinned to a pool or a provider
but never to the cluster or to a single machine: it simply errored.

Cluster sources now round-trip through the provider route vocabulary
with the same "cluster" and "node:<uuid>" spelling a request body uses,
and the route middleware rejects a node that is not a member up front
instead of failing later inside the router.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
nodeEngine.forward built a fresh http.Client, and with it a fresh
http.Transport, for every forwarded inference request, and unlike the
manager's own calls it never closed the idle connections afterwards. No
connection was ever reused, so each forwarded request paid a full TLS
handshake, and every request left a transport behind holding up to
eight idle connections until they timed out.

A member now keeps one client, keyed by its pinned certificate
fingerprint so that re-pinning replaces it rather than reusing a client
that still trusts the old certificate. Unpinned callers (joins and seed
probes) still get a throwaway client: they run once and trust any
certificate, so pooling them would keep a permissive connection alive
for nothing. Dropping a member now releases its connections too, which
forgetNode does alongside forgetting its health and addresses.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Plumbing had accumulated near-copies. The address-and-port idiom was
written in seven places with two slightly different answers for a
missing port; a helper for exactly that, withPort, had been written in
directory.go and never called. The non-2xx-to-peerError conversion was
eight identical lines in three functions. Two JSON writer pairs existed
in one package, differing only in whether they carried the machine
code. itoaInt, containsString and minU64 restated strconv.Itoa,
slices.Contains and the builtin min.

Also removed: a "now" that was computed and then explicitly discarded,
and two var _ = lines left in internal/server/cluster.go to keep
imports that nothing needed any more.

No behaviour changes; the address helpers make the default-port answer
the same everywhere it is reached.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
…try-After

Four near-copies of one shape lived in the routed worker engines: build a
body, ask routeOrLocal, and either run locally while holding the
reservation or decode a peer's answer. Releasing the reservation before
the local work ran is what once made balanced mode never move anything,
and that invariant was restated four times. It now exists once, in
routedCall, with the two engines embedding a shared routedWorker. The
two engine resolvers, which differed only in which engine they built,
became one.

The two Chat implementations differed by three lines. They are now one
function, and it always sends num_ctx: a routed Ollama-style chat used
to drop it, so the node that loaded the model never heard the requested
context size.

A 429 from a member is now a cooldown, honouring Retry-After in either
header form, rather than an immediate retry on the next request. The
provider pool has behaved this way since it was written and the design
doc specified it for the cluster too.

Local status was cached twice with two two-second windows, and the
invalidation after a settings change could only reach one of them. The
peer status handler now shares the manager's cache and the second one
is gone.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Where a request runs is decided by its "source" field, whose vocabulary
now spans five namespaces owned by three packages. Which one wins was
written out four times, once per kind of request, and the copies had
drifted: one compared the lowercased source, another compared the raw
one, and none of them agreed on what an unrecognised source should do.
Adding the cluster to only three of the four is what left AI apps
unable to target it.

routeForSource is now the single answer to "where does this go". Chat,
embeddings and the speech engines all ask it and then build the backend
their own kind of request needs, which is the part that genuinely
differs. A speech request that names a pool, a provider or the cloud is
now refused rather than quietly served here, and the failed-local-load
fallback to a member is one predicate instead of three spellings.

The perf store moved to its own file. It is persistence of measured
throughput with no tie to membership, discovery or the HTTP surfaces,
and shared manager.go with them only by accident.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
ganisback and others added 2 commits September 19, 2026 19:22
The identity test asserted that the node private key is not readable by
group or other. Windows has no Unix permission bits, and Go reports 0666
for any file it can write there, so the assertion failed on that platform
for a file that was written correctly. Access on Windows is governed by
the directory ACL instead, which the mode cannot express.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Removing the Python embedding runtime left two symbols behind: the script
that mirrored the worker's import block, and the sync.Map that cached
whether that check had passed. Both were only ever read by the
verification functions that went with the runtime, so nothing refers to
them now. Unexported package-level declarations are not reported as
unused, which is why the build stayed quiet.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
ganisback and others added 4 commits September 19, 2026 23:33
**A peer's file names were trusted.** A model bundle lists the files to
copy, and the receiving node joined each name onto its model directory
without looking at it. The node serving a file checks the name, but a
member that is compromised or simply running something else does not run
our sending code, so a name of "../../.." wrote wherever the server user
could write. The bundle is now rejected outright when any path is not a
plain relative one, and the receiver checks again that what it is about
to create stays under the directory it belongs in.

**The join token was readable by any page and any machine.** The design
said these endpoints were same-origin and loopback only; nothing
implemented it. /api/cluster/* answered with a wildcard CORS header and
no origin check, so a site the user visited could read the token and put
a machine into their cluster, and nothing on the network needed a key
because only the inference paths were listed as needing one. Cluster
routes are now refused cross-origin and get no wildcard header, they
count as remote-authenticated like inference, and the three that hand out
the join token or admission code answer over loopback or to a caller
holding a key, never on trust alone.

**Gossip could grow the cluster past its licensed size.** Joining is
capped; merging a peer's member cards was not. The effect was not extra
capacity but the opposite: over the cap every node reports itself
unlicensed and the scheduler excludes all of them, so one member could
take the cluster out of service. The merge now applies the same cap and
logs what it ignored.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
A bundle path is relative and forward-slash by definition, but the guard
leaned on filepath.IsAbs, which calls "/etc/passwd" relative on Windows.
The name was then joined under the destination rather than refused: it
stayed inside the directory, so nothing escaped, but the same input meant
two different things depending on the platform. A leading slash and a
backslash are now refused outright wherever the code runs, which is what
the node serving the file already does.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
The first thing on the page was a screenshot of the AI apps grid, which
says what one page of the web UI looks like rather than what the project
is. The diagram in its place answers the question a reader actually
arrives with: what runs where. It has two halves, because the answer
differs once there is more than one machine. The screenshot is dropped
rather than moved, since nothing else referenced it and a picture of an
older UI only goes stale.

The cluster also gets a line in the feature list, where it was missing
entirely.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
ee/ held one directory of twenty-four flat files, three of which were
grab-bags: manager.go ran lifecycle, discovery, status, peer calls,
polling, gossip and every membership operation across 1500 lines;
engine.go held two engines, the routing errors, the throughput
measurement and the model inventory; api.go held twenty-eight handlers
alongside the view mapping. Nobody adding a feature could tell where a
new declaration belonged, which is how those three files grew.

Each file now holds a single subject and says so, and ee/README.md
lists what goes where so the next change does not have to guess. The
declarations moved by their AST positions, so this is a move and
nothing else.

The two HTTP surfaces also stop being methods on Manager. /api/cluster
hangs off an adminAPI and /cluster/v1 off a peerAPI, both thin faces
over the manager, which takes it from 116 methods to 83: adding an
endpoint no longer grows the type that runs discovery, gossip and
scheduling.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
ganisback and others added 2 commits September 21, 2026 00:09
ee/LICENSE held an English-only draft. This is the licence OpenCSG
actually issues, reproduced verbatim from the signed document in both
languages, Chinese first because the Chinese text governs where the two
differ.

It is materially wider than the draft it replaces: production use is
defined to include a pilot, a proof of concept or a demo that carries
real business rather than only a live deployment; modifying the logo or
the UI appearance, and any commercial use, require written notice to
OpenCSG whether or not a licence is held; and the termination, warranty,
third-party component, precedence and arbitration clauses were absent
before. The summary in ee/README.md is updated to match.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Three changes to ee/LICENSE.

The issued Chinese text lost the number on its fourth heading: it read
"使用限制" where the English reads "4. Use Restrictions" and every other
heading from one to ten is numbered. The English text spelled "except"
as "excepct" in the clause that says the Chinese version prevails. Both
are corrected here and should be corrected in the source document too.

The third is substantive. The licence permits production use only with
a valid Enterprise licence, while the product deliberately gives every
user a two-node cluster without one, so the code and the licence
contradicted each other. A community grant now sits at the top of the
file, ahead of the issued text and clearly separated from it: a feature
that runs without a licence may be used in production within the limit
the software itself enforces for unlicensed users. Everything else in
the licence is untouched and still applies.

The grant is drafted, not reviewed. It needs legal sign-off before
release.

Generated with AI

Co-Authored-By: csglite <xzhgan@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

增加局域网内多个电脑(算力节点)组网功能

1 participant