Routing overview
此内容尚不支持你的语言。
A router is an actor whose only job is to forward each incoming
message to one (or more) of its routees. You spawn the router
once; behind it sit N worker actors; senders tell the router and
never know how many routees exist.
Two shapes ship with the framework:
| Form | Where it lives | Routees are… |
|---|---|---|
Router (local) | One node, one actor system. | Children of the router, created at spawn time from the same actor. |
ClusterRouter | Across cluster nodes. | Up-members of the cluster, derived from membership + a routee path. |
Both expose the same external surface: a single ActorRef<TMessage>
that callers tell. The difference is what’s on the other side.
When to reach for a router
Section titled “When to reach for a router”Three patterns:
- Parallelize CPU-heavy work. A single actor is bottlenecked by its one-at-a-time guarantee; a 4-routee round-robin router gives you 4-way parallelism without breaking message ordering within a routee.
- Spread load across nodes. A cluster router with role-filtered
routees gives you fan-out across every node carrying the
'compute'role. Add a node, the router picks it up; remove a node, the router stops sending to it. - Pin work to a routee deterministically. Consistent-hashing (cluster only) puts every message with the same key on the same routee — useful when each routee maintains per-key state (a cache, a session, a counter).
- Race N routees for the same answer. Scatter/gather asks every routee the same question and returns whichever replies first, trading N times the work for the minimum of N latencies. That is a different goal from the other three — tail latency, not throughput. See Scatter/gather.
A minimal example
Section titled “A minimal example”import { ActorSystem, Router, 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('demo');
const pool = system.spawn( Router.roundRobin(4, Worker), 'workers',);
pool.tell({ payload: 'a' }); // → worker-1pool.tell({ payload: 'b' }); // → worker-2pool.tell({ payload: 'c' }); // → worker-3pool.tell({ payload: 'd' }); // → worker-4pool.tell({ payload: 'e' }); // → worker-1 (round-robin wraps)The pool ref looks like a single actor to callers; under the hood
the routing actor cycles through four Worker children.
The five strategies
Section titled “The five strategies”| Strategy | What it does | Best for |
|---|---|---|
round-robin | One routee per message, cycling. Even distribution by message count. | Homogeneous workloads. |
random | One routee per message, uniformly random. | Same as round-robin, but stateless. |
broadcast | Every routee gets every message. | Notifications, cache invalidations. |
smallest-mailbox | One routee per message, the one with the shortest queue. | Uneven per-message cost. |
consistent-hashing (cluster only) | One routee per message, key-pinned. | Per-key state (sharding-lite). |
smallest-mailbox is the one that balances by backlog instead of
by message count. Round-robin hands out turns blindly, so a routee
that drew an expensive message still gets its next 1-in-N on schedule
and falls behind; smallest-mailbox reads each routee’s queue depth and
picks the shallowest, so a busy routee stops being chosen until it
catches up. It pays for that with one depth read per routee per
message, which is why round-robin remains the default.
Both routers have it, but they learn the depth differently. The local one reads its routees’ mailboxes directly — they are its own children, in the same process. The cluster one cannot: a remote mailbox is on another node, and asking per message would park the router behind a network round-trip. It routes on a cached depth instead, refreshed on a background tick, which trades a little freshness for a routing decision that stays synchronous. See Cluster router for what that lag can and cannot cost.
See Strategies for the deep dive,
plus the Broadcast message wrapper that overrides the strategy
per-message.
All five are one-shot: the router picks routees, forwards, and is
done — the reply, if there is one, is between the routee and the
original sender. Router.scatterGatherFirstCompleted is the one
router that does not fit that shape: it asks every routee and answers
the caller itself with the first reply that arrives. It is therefore
not a strategy you can hand to Router.custom, but its own factory —
see Scatter/gather.
Pool vs group
Section titled “Pool vs group”The local Router is always a pool — it creates its routees
itself. The cluster ClusterRouter is more like a group — the
routees already exist (one actor per up-member at a well-known
path), and the router just finds them.
For most cases, pool is what you want. The group model surfaces when you want existing actors (e.g. shard regions, fixed-name workers spawned at startup) to receive routed traffic.
See Pool vs group for when each shape is the right fit.
Routers and supervision
Section titled “Routers and supervision”The router is a regular actor — it has its own supervisor strategy,
and it watches each routee. When a routee crashes:
- Without intervention, the routee’s parent (the router) applies
its supervisor strategy. By default that’s the framework’s
defaultStrategy— Restart up to 10/minute. - The restarted routee re-joins the pool at the same path. The router doesn’t have to do anything special.
- Anything routed to the routee during its brief restart window stays in its mailbox — the mailbox is suspended for the restart and resumed afterwards (the same instance, not drained), so those messages are processed once the routee is running again.
For per-routee supervision strategies, configure them on the routee options:
import { ActorOptions } from 'actor-ts';
const routeeOptions = ActorOptions.create().withSupervisorStrategy(stoppingStrategy);
system.spawn(Router.roundRobin(4, Worker, routeeOptions), 'workers');Now any worker that throws is stopped instead of restarted — and the router watches it die, but its ref stays in the pool. Round-robin keeps handing that slot roughly 1-in-N messages, which land in dead letters until the whole router restarts. You’d combine this with a higher-level supervisor that decides when to re-spawn the whole pool.
Routers are not (the only) way to parallelize
Section titled “Routers are not (the only) way to parallelize”A router gives you N independent actors processing one message
at a time each. Two alternatives worth knowing:
- Sharding is the right tool when each unit of work has a key and you need exactly one live actor per key (with failover, rebalancing, etc.). See Sharding.
- DistributedPubSub is the right tool for fan-out where the subscriber set is dynamic — actors come and go, and any of them can receive published events. See DistributedPubSub.
Routing is a fixed-size pool with a deterministic strategy. When that’s what you need, it’s the simplest tool; when it isn’t, reach for something else.
Where to next
Section titled “Where to next”- Router — the
Router.roundRobin(...),.random(...),.broadcast(...),.smallestMailbox(...),.custom(...)factories. - Strategies — what each strategy does, plus writing your own.
- Scatter/gather — ask every routee at once and answer with the first reply.
- Pool vs group — when to use a router that spawns its routees vs one that finds them.
- Cluster router — the membership-driven cluster equivalent.
