跳转到内容
简体中文

Routing strategies

此内容尚不支持你的语言。

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.

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 messageIndex lands 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.

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.

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 Refresh message 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.

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.

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.

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.

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=42 always 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.

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.

  • 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.