Routing strategies
Ce contenu n’est pas encore disponible dans votre langue.
A routing strategy is a function: given the routee list and a bit of state, return which routee(s) should receive the next message.
type RoutingStrategy = ( routees: ReadonlyArray<ActorRef>, state: { readonly messageIndex: number },) => Iterable<ActorRef>;Return one ref for single-target routing, multiple for fan-out, or
nothing to drop. The local router ships four implementations
plus a custom slot; the cluster router has a counterpart for each
of the four and adds consistent-hashing.
Round-robin
Section titled “Round-robin”Router.roundRobin(4, routee);Cycles through the routee list — message 1 → routee 1, message 2 → routee 2, …, message 5 → routee 1 again. Implementation:
function roundRobinStrategy(): RoutingStrategy { return (routees, state) => { if (routees.length === 0) return []; return [routees[state.messageIndex % routees.length]]; };}Picks:
- Even distribution by message count (not by message cost).
- Deterministic and inspectable — a debugger sees exactly which routee got each message.
- Re-routing on resize: if a routee disappears or a new one
appears, the same
messageIndexlands on a different routee.
Doesn’t:
- Load-balance by work cost. Message 100 might be a heavy job; the router doesn’t know. If one routee gets all the expensive jobs by chance, it falls behind.
- Provide a “stick to the same routee for related messages” guarantee. Use consistent-hashing for that.
Right default for homogeneous workloads — message processing times are roughly equal across messages.
Random
Section titled “Random”Router.random(4, routee);function randomStrategy(): RoutingStrategy { return (routees) => { if (routees.length === 0) return []; return [routees[Math.floor(Math.random() * routees.length)]]; };}Picks a routee uniformly at random.
Picks:
- Same statistical distribution as round-robin in the long run, but no shared state — useful in stateless / pure functional setups.
- More resilient to “synchronized” senders. If two callers cooperate with their own indices, round-robin can end up hammering the same routees; random doesn’t.
Doesn’t:
- Give you deterministic behavior in tests. Inject a seedable RNG and write a custom strategy if reproducibility matters.
Right choice when statelessness matters more than predictability.
Broadcast
Section titled “Broadcast”Router.broadcast(4, routee);function broadcastStrategy(): RoutingStrategy { return (routees) => routees; // every routee}Sends every message to every routee. The pool runs in lockstep — useful for fan-out shapes:
- Cache invalidation: every routee holds a cache; when a key changes, broadcast tells them all.
- Periodic refresh: every routee re-reads config when a
Refreshmessage arrives. - Heartbeat: every routee checks in on a tick.
Picks:
- N-way fan-out with N-way work. Each message is processed N times. Total throughput is N × per-routee throughput, but each routee sees the full message load.
Doesn’t:
- Parallelize work — every routee does the same work. This is fan-out, not load-balancing.
- Make sense for request/response — every routee replies, the caller sees N replies.
If you want broadcast for some messages but routing for others,
keep the router non-broadcast and wrap occasional messages in
Broadcast<T> — see Router.
Smallest mailbox
Section titled “Smallest mailbox”Router.smallestMailbox(4, routee);function smallestMailboxStrategy(): RoutingStrategy { return (routees, state) => { if (routees.length === 0) return []; // Start the scan on a different routee each message, so an // all-equal pool rotates instead of pinning routee 1. const start = state.messageIndex % routees.length; let shallowest: ActorRef | null = null; let shallowestDepth = 0; for (let offset = 0; offset < routees.length; offset++) { const routee = routees[(start + offset) % routees.length]; // `null` means "do not route here at all" — a stopped routee. // A routee whose depth is merely unreadable comes back as 0. const depth = routableDepthOf(routee); if (depth === null) continue; if (shallowest === null || depth < shallowestDepth) { shallowest = routee; shallowestDepth = depth; } } return [shallowest ?? routees[start]]; };}Reads how many messages are queued at each routee and picks the shallowest. This is the only built-in strategy that reacts to what the routees are actually doing.
Picks:
- Balance by backlog, not by turn. Round-robin gives a routee its next 1-in-N whether or not it is still working on the last one. Smallest-mailbox stops choosing a routee that has fallen behind, and resumes when it catches up — which is exactly the workload-aware load-balancing round-robin cannot do.
- Self-correcting after a stall. A routee blocked on a slow downstream call drains no queue, so its depth stays high and it simply stops receiving traffic. No timer, no health check.
- Rotating ties. All-equal depths — an idle pool, or a saturated one — fall back to round-robin order rather than hammering the first routee.
- Ignore a stopped routee. A routee that has terminated is skipped outright rather than measured — see What counts as a depth below for why that case cannot be left to the number.
Doesn’t:
- Come free. One depth read per routee per message: a modulo for
round-robin versus an
O(N)scan here. For homogeneous workloads that is pure overhead. - Know about cost, only about count. Ten trivial messages look deeper than one enormous one. Queue length is a proxy for load, not a measurement of it.
- Apply back-pressure. The message is routed to the shallowest
routee whatever that depth is; a strategy that refused to route would
be inventing back-pressure the caller never configured. On the
unbounded default there is no “does not fit”, so depth stays a true
reading of backlog however far behind the pool falls. Bound the
routees and the depths tie once they all sit at capacity, at which
point the rotation takes over and the overflow policy (
drop-head/drop-new/reject) decides what happens to the message. - Read a remote mailbox live. Depth is in-process state. The cluster variant below gets it across nodes, but only as a cached reading.
Right choice when per-message cost varies widely — mixed job sizes, routees that call out to something slow.
What counts as a depth
Section titled “What counts as a depth”Two kinds of routee have no usable queue length, and they are handled as opposites on purpose:
- A stopped routee is never chosen. Its mailbox does not merely look empty — it is empty, permanently, because a terminated cell sends straight to dead letters instead of enqueueing. Read as a plain number, that would make a dead routee the most attractive member of the pool and keep it that way until the router notices the death and prunes it, losing every message routed in between. The router learns of the death by an ordinary message, so under load that window is as deep as the router’s own queue. Termination is therefore checked before the depth is, and a stopped routee drops out of the scan entirely.
- A routee whose depth is unreadable counts as empty. Only a locally-hosted actor has a mailbox this process can look into, so a remote or otherwise foreign ref is unmeasurable. The strategy assumes it is not backed up rather than skipping it: skipping would starve it for as long as any local routee has a backlog, which in a mixed pool is forever. The trade-off runs the other way instead — while the local routees are busy, an unreadable one looks shallowest and takes the traffic. That is a balance error and it corrects itself; the starvation did not. Several unreadable routees tie at zero and rotate among themselves.
If every routee has stopped, the scan falls back to the rotation
and routes into a dead cell anyway. The message is lost either way,
and losing it as a DeadLetter is at least observable, where
returning no routee at all would drop it without a trace.
The cluster variant
Section titled “The cluster variant”import { ClusterRouter, ClusterRouterOptions } from 'actor-ts';
const clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRouterType('smallest-mailbox') .withRouteePath('/user/worker');ClusterRouter.factory( clusterRouterOptions,);ClusterRouter has the same strategy, reached the only way it can
be. A remote routee’s mailbox is not readable from here, and asking
it per message would park the router’s whole mailbox behind a network
round-trip — so each node’s depth is cached and refreshed on a
background tick, and the routing decision reads the cache
synchronously.
Two consequences worth carrying:
- The depth can be up to one refresh interval old, so a node may be chosen just after it filled up. The next tick corrects it.
- A node that has not reported is skipped rather than assumed idle, and a cache with nothing in it falls back to round-robin order.
Cluster router covers the refresh interval, the staleness bound, and the one-line setup a node needs when it hosts routees but no router.
Consistent-hashing (cluster only)
Section titled “Consistent-hashing (cluster only)”import { ClusterRouter } from 'actor-ts';
ClusterRouter.factory({ cluster, routerType: 'consistent-hashing', routeePath: '/user/worker', extractKey: (message) => message.userId,});Computes a hash of extractKey(message) and picks the routee whose
own hash is closest (rendezvous hashing). Same key → same routee,
deterministically, across the cluster.
Picks:
- Stickiness. A long-running stream of messages tagged
userId=42always lands on the same routee. The routee can maintain per-key state (cache, in-progress session) without a coordinator. - Topology-stable. Adding or removing a routee only reshuffles
the keys whose nearest hash changed — a fraction proportional to
1/N, not all of them.
Doesn’t:
- Balance perfectly under skewed-key workloads. If 80 % of traffic
is
userId=42, that one routee carries 80 % of the load. Skewed keys need a different approach — see Sharding for the heavier per-key-actor pattern. - Pin to a fixed routee. Topology changes do shuffle some keys; for hard guarantees, use a singleton or sharded entity.
Right choice for session-affine routing in cluster setups where the key space is reasonably uniform.
Custom strategies
Section titled “Custom strategies”import { Router, type RoutingStrategy } from 'actor-ts';
// Always route to the first routee for the first 100 messages// (warm one cache before spreading load), then round-robin.const warmupStrategy: RoutingStrategy = (routees, state) => { if (routees.length === 0) return []; if (state.messageIndex < 100) return [routees[0]]; return [routees[state.messageIndex % routees.length]];};
system.spawnAnonymous(Router.custom(4, Worker, warmupStrategy));Anything that satisfies RoutingStrategy works. The state slot
gets the monotonic message index — that’s the only state the local
router maintains. For strategies that need more state (a hash
ring, a per-routee weight table, a sliding-window latency estimate),
close over your own state in the function:
// Weighted round-robin: routee 1 has twice the capacity of the rest.function weightedStrategy(weights: readonly number[]): RoutingStrategy { const slots: number[] = []; weights.forEach((weight, index) => { for (let i = 0; i < weight; i++) slots.push(index); }); return (routees, state) => { if (routees.length === 0) return []; return [routees[slots[state.messageIndex % slots.length] % routees.length]]; };}A custom strategy sees the routee refs and the message index — no
more. Anything else it wants to know, it has to carry itself. Note
what it cannot reach: mailbox depth is runtime-internal state and
is deliberately not on ActorRef, so a load-aware strategy of your
own would need each routee to report its own load back to a shared
gauge. If shortest-queue is what you’re after, use the built-in
Router.smallestMailbox — it reads the depth from inside the
framework, where that state legitimately lives.
What’s not implemented
Section titled “What’s not implemented”Where to next
Section titled “Where to next”- Router — the factories that wrap each strategy in a ready-to-spawn actor factory.
- Pool vs group — how these strategies behave when applied to a fixed pool vs a dynamic group of routees.
- Cluster router — where consistent-hashing lives.
- Sharding — the heavier alternative when keys need true per-key actors.
