Ir al contenido
Español

Cluster router

Esta página aún no está disponible en tu idioma.

The local Router creates its routees as its own children — a fixed pool on one node. The ClusterRouter is different: its routees are other nodes’ actors at a well-known path, and the routee set changes as cluster membership changes.

ClusterRouter on node-A

node-A's

/user/worker

node-B's

/user/worker

node-C's

/user/worker

one per up-member with role=compute

Every up-member with role compute (configurable) has a worker at /user/worker; the router routes incoming messages across them per a strategy. Add a node, the router picks it up; remove a node, the router stops sending to it — no restart needed.

See Pool vs group for the distinction between local pools and cluster groups.

import { ActorSystem, Cluster, ClusterOptions, ClusterRouter, ClusterRouterOptions, Actor } from 'actor-ts';
class Worker extends Actor<{ payload: string }> {
override onReceive(message: { payload: string }): void {
this.log.info(`worked on ${message.payload}`);
}
}
const system = ActorSystem.create('my-app');
const clusterOptions = ClusterOptions.create()
.withHost(host)
.withPort(port)
.withSeeds(seeds)
.withRoles(['compute']);
const cluster = await Cluster.join(
system,
clusterOptions,
);
// 1. Every node spawns its own worker at /user/worker
system.spawn(Worker, 'worker');
// 2. Any node can build a cluster router targeting these workers
const clusterRouterOptions = ClusterRouterOptions.create()
.withCluster(cluster)
.withRouterType('round-robin')
.withRouteePath('/user/worker')
.withRole('compute');
const router = system.spawn(
ClusterRouter.factory(
clusterRouterOptions,
),
'compute-router',
);
// 3. Tell the router — message gets routed to one node's worker
router.tell({ payload: 'work-1' });

The pattern: each node deploys the routee actors locally; one (or more) nodes spawn a ClusterRouter that targets them. The router’s strategy decides which node’s worker handles each message.

type ClusterRouterOptions<TMessage> = {
cluster: Cluster;
routerType: 'round-robin' | 'random' | 'consistent-hashing'
| 'smallest-mailbox' | 'broadcast';
routeePath: string;
role?: string;
extractKey?: (message: TMessage) => string;
mailboxDepthRefreshMs?: number;
mailboxDepthStaleAfterMs?: number;
};
FieldRequiredWhat
clusterYesThe cluster — used for membership tracking + the wire transport.
routerTypeYesOne of the five strategies.
routeePathYesThe path the routee actor lives at on each node (typically /user/<actorName>).
roleNoIf set, only members carrying this role are routees.
extractKeyWhen routerType: 'consistent-hashing'Extracts the routing key from a message.
mailboxDepthRefreshMsNo (default 200)smallest-mailbox only — how often the cached depths are refreshed.
mailboxDepthStaleAfterMsNo (default 1000)smallest-mailbox only — how long a cached depth counts. 0 turns the expiry off.
StrategyWhat it does
'round-robin'One routee per message, cycling.
'random'One routee per message, uniformly random.
'consistent-hashing'Pin same extractKey to same routee via rendezvous hashing.
'smallest-mailbox'One routee per message, the node with the shortest reported queue.
'broadcast'Send to every routee.

The first four are 1-of-N routing; broadcast is fan-out. See Strategies for the picking guidance — same trade-offs apply, just spread across cluster nodes instead of pool members.

const clusterRouterOptions = ClusterRouterOptions.create()
.withCluster(cluster)
.withRouterType('consistent-hashing')
.withRouteePath('/user/cache')
.withExtractKey((message) => message.userId);
ClusterRouter.factory(
clusterRouterOptions,
);

Required for 'consistent-hashing'. The function pulls a string key out of each message; the router pins messages with the same key to the same node via rendezvous hashing.

Useful when each routee maintains per-key state: a cache of that user’s data, a session, an in-progress workflow. Topology changes shuffle a fraction of keys (proportional to the add/remove), not all of them.

If extractKey returns the same value forever, every message goes to the same routee (effectively a singleton). Make sure it varies across your actual workload.

const clusterRouterOptions = ClusterRouterOptions.create()
.withCluster(cluster)
.withRouterType('smallest-mailbox')
.withRouteePath('/user/worker')
.withRole('compute');
ClusterRouter.factory(
clusterRouterOptions,
);

Routes each message to the node whose routee last reported the shortest queue — the cluster counterpart of the local Router.smallestMailbox. Same reason to reach for it: when per-message cost varies widely, round-robin keeps handing a node its next turn while it is still behind, and this one does not.

It never asks on the routing path. A router routes synchronously, so a query-per-message would park the router’s whole mailbox behind a network round-trip and reorder everything behind it. Instead each node’s depth is cached and refreshed on a background tick:

node-B

node-A

every mailboxDepthRefreshMs

reads queue depth

report

read synchronously,

per message

ClusterRouter

smallest-mailbox

cached depths

per node

mailbox-depth agent

/user/worker

The routee is an ordinary actor of yours and never learns it is being measured: a small framework agent on each node answers for it, reading the queue depth from inside the runtime.

What that buys, and what it costs:

  • The routing decision stays as cheap as round-robin’s — a scan over a local map, no await.
  • The picture lags by up to one refresh interval. A node can be chosen just after it filled up. That is a worse decision than the local strategy’s, never a wrong one: the message is delivered, and the next tick corrects the view.
  • A node that has not answered is skipped, not assumed idle — the silent node may be the struggling one. If no node has answered yet, the router falls back to round-robin order, so a cold cache is a degraded router and never a dropped message.

A smallest-mailbox router starts the agent on its own node, which covers a single node and the common homogeneous deployment (every node runs both the router and the routees). On a node that hosts routees but no router, start it yourself:

import { ClusterMailboxDepthAgent } from 'actor-ts';
const stopServingDepths = ClusterMailboxDepthAgent.serve(cluster);

Forget it and nothing breaks: that node simply never reports, so routers skip it while every other node is answering, and fall back to round-robin order when none is.

mailboxDepthRefreshMs is the lag on the router’s picture and the rate at which it costs one small envelope per routee. mailboxDepthStaleAfterMs is how long a reading survives without a replacement — set it to 0 to keep readings forever, which is only sensible when you would rather route on an old number than on the rotation. A window shorter than the refresh that refills it is rejected at construction: every reading would expire before its replacement arrived, leaving the cache permanently cold.

Every gossip round, the router re-derives its routee set from the cluster’s up-members. Triggers a rebuild on:

  • MemberUp — a new up-member matching the role. Add it.
  • MemberRemoved — a removed member. Drop it.

The set is ordered deterministically (by address), so round-robin counters stay sane across rebuilds.

router.tell({ payload: 'a' });
// → if no up-members match `role`, message is dropped with a warning log

Important: an empty routee set means messages drop to dead letters. The framework doesn’t queue while waiting for routees — that would silently grow without bound.

For “start serving once the pool has at least N routees,” subscribe to MemberUp and gate request handling on a counter.

const clusterRouterOptions = ClusterRouterOptions.create()
.withCluster(cluster)
.withRole('compute');
ClusterRouter.factory(
clusterRouterOptions
// ...
);

Only up-members carrying the compute role are candidates. Useful for asymmetric clusters:

  • Nodes with compute role do heavy work.
  • Nodes with gateway role handle HTTP traffic.
  • Nodes with coordinator role host singletons.

The role is declared at Cluster.join time per node. The router’s role field filters; without it, every up-member is a candidate.

If the local node is a candidate (matches the role), the router may route to a worker on the same node. The transport handles loopback the same as any cross-node delivery — through the transport’s local-loopback path.

This means the router’s load distribution is symmetric — no preference for local routees, no penalty either. Round-robin puts you in the cycle like every other node.

router.stop();
// or: router.tell(PoisonPill.instance);

Stops the router actor. Routees are unaffected — they’re on other nodes; they keep running. This is the group-router model: the router owns the routing, not the routees themselves.

For comparison, a local pool router cascade-stops its routees on stop. See Pool vs group.

The ClusterRouter API reference covers the full options.