You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Existing routers route by external policy (round-robin, random, hash) — they don't look at routee load. Akka's SmallestMailboxRouter peeks each routee's mailbox depth and picks the routee with the least work in flight. Win for heterogeneous workloads where some messages take much longer than others (round-robin can starve fast routees with slow predecessors).
Implementation is straightforward — Mailbox already exposes .size (src/internal/Mailbox.ts:65). The routing strategy reads sizes and picks the minimum.
Reference: what Akka does
valpool= system.actorOf(SmallestMailboxPool(5).props(Props[Worker]()))
pool ! work // routed to the worker with the smallest mailbox
Akka's exact selection order:
Routees with 0 messages and no active processing.
Routees with lowest mailbox count (excluding routees actively processing).
Routees that are currently processing but with the smallest backlog.
Random if all routees are equally busy.
The 4-level order avoids "stack everything on the first idle routee" pathology.
Design sketch — actor-ts equivalent
// src/Router.ts — add to the existing strategies/** * Picks the routee whose mailbox has the fewest pending messages. * Ties broken by the round-robin counter (so equal-depth routees rotate). * * Note: reading mailbox depth is O(1) for the default in-memory mailbox. * Custom mailbox implementations that don't expose `size` cheaply (e.g., * a queue backed by Redis) MUST override `size` to be cheap or this * strategy degrades. */exportfunctionsmallestMailboxStrategy(): RoutingStrategy{return(routees,state)=>{if(routees.length===0)return[];letbestIdx=0;letbestSize=Number.POSITIVE_INFINITY;// Iterate in rotation-offset order so ties between equal-depth routees// round-robin instead of always picking index 0.constoffset=state.messageIndex%routees.length;for(leti=0;i<routees.length;i++){constidx=(i+offset)%routees.length;constmboxSize=mailboxSizeOf(routees[idx]!);if(mboxSize<bestSize){bestSize=mboxSize;bestIdx=idx;if(bestSize===0)break;// can't beat empty}}return[routees[bestIdx]!];};}functionmailboxSizeOf(ref: ActorRef): number{// Local actor → read mailbox directly via internal accessor.// Remote actor → return 0 (we don't have visibility; smallest-mailbox// routing across the cluster doesn't make sense without a separate// metric channel).constcell=(refas{_cell?: {mailbox: {size: number}}})._cell;returncell?.mailbox.size??0;}
And the convenience factory in the existing Router object:
Cluster router: not covered. ClusterRouter (in src/cluster/router/ClusterRouter.ts) routes across nodes; smallest-mailbox there would need a metric channel from each remote routee. Out of scope for this issue.
Active-processing awareness — Akka's full algorithm tracks whether the routee is currently processing a message. Our Mailbox.size is queue-only (doesn't include the in-flight message). Difference: at most 1 off per routee. Acceptable for the 80% case; document the caveat.
Weighted smallest-mailbox — routees with different capacities. Out of scope.
Group variant for already-spawned routees — same shape as Router.scatterGatherFirstCompletedGroup from [Feature] ScatterGatherFirstCompletedRouter #153. Add for consistency if both ship together.
Open design questions
Custom mailbox accessor: the sketch reaches into _cell privates. Cleaner: add a public actorRef.mailboxSize: number accessor. Surfaces "implementation detail" but lets users build their own strategies. Recommend: add it.
Bounded mailbox: if a routee uses a bounded mailbox (separate issue) and is at capacity, should we skip it (overflow risk) or still pick it (fairness)? Recommend: skip; if no routees are available, fall back to round-robin.
Tie-break stability: rotating offset (sketch) vs. random vs. strict-lowest-index. Rotating gives best fairness; random adds noise. Recommend: rotating.
Cost of polling: at high message rate, reading 100 routee mailbox sizes per message is 100 reads/msg. For 100K msg/sec × 100 routees = 10M reads/sec → ~50ms CPU/sec. Acceptable; document.
Test plan
Empty pool delivery — 5 routees, all empty; first message goes to routee 0; second to routee 1 (rotation breaks tie).
Unbalanced load — pre-fill routee 0 with 10 messages; send 5 new messages; all 5 go to routees 1-5 (smaller mailboxes).
Single hot routee — routee 0 is processing a slow message (queue grows to 50); next message goes to a different routee, not 0.
All-equal fairness — all routees at exactly depth 3; verify rotation across messages.
Routee death — one routee terminates; router skips it.
Remote routee mailbox — mailboxSizeOf returns 0 for RemoteActorRef; route as if empty.
Broadcast still works — router.tell(new Broadcast(msg)) bypasses strategy, sends to all.
Stress — 10K messages through 10 routees; verify approximately balanced final mailbox sizes.
Empty pool — 0 routees; tell drops (with warning).
Cross-runtime parity — Bun, Node, Deno.
Acceptance criteria
smallestMailboxStrategy() exported from src/Router.ts.
Size / Priority
Rationale
Existing routers route by external policy (round-robin, random, hash) — they don't look at routee load. Akka's
SmallestMailboxRouterpeeks each routee's mailbox depth and picks the routee with the least work in flight. Win for heterogeneous workloads where some messages take much longer than others (round-robin can starve fast routees with slow predecessors).Implementation is straightforward —
Mailboxalready exposes.size(src/internal/Mailbox.ts:65). The routing strategy reads sizes and picks the minimum.Reference: what Akka does
Akka's exact selection order:
The 4-level order avoids "stack everything on the first idle routee" pathology.
Design sketch — actor-ts equivalent
And the convenience factory in the existing
Routerobject:Integration with existing actor-ts subsystems
Router.ts: adds one strategy + one factory entry. ~30 lines.Mailbox.size: already exists (src/internal/Mailbox.ts:65). No mailbox API change.mailboxSizeOfreturns 0 for non-local refs. Documented limitation; if remote-aware routing is wanted, use [Feature] Dispatcher-saturation + mailbox-depth histograms #196's dispatcher-saturation Gauges + a custom strategy.ClusterRouter(insrc/cluster/router/ClusterRouter.ts) routes across nodes; smallest-mailbox there would need a metric channel from each remote routee. Out of scope for this issue.Out of scope / non-goals
Mailbox.sizeis queue-only (doesn't include the in-flight message). Difference: at most 1 off per routee. Acceptable for the 80% case; document the caveat.Groupvariant for already-spawned routees — same shape asRouter.scatterGatherFirstCompletedGroupfrom [Feature] ScatterGatherFirstCompletedRouter #153. Add for consistency if both ship together.Open design questions
_cellprivates. Cleaner: add a publicactorRef.mailboxSize: numberaccessor. Surfaces "implementation detail" but lets users build their own strategies. Recommend: add it.Test plan
mailboxSizeOfreturns 0 forRemoteActorRef; route as if empty.Broadcaststill works —router.tell(new Broadcast(msg))bypasses strategy, sends to all.Acceptance criteria
smallestMailboxStrategy()exported fromsrc/Router.ts.Router.smallestMailbox(size, routeeProps)factory exported.Router.smallestMailboxGroup(routees)for the pre-spawned-routees variant.