Skip to content

[Feature] SmallestMailboxRouter #154

Description

@pathosDev

Size / Priority

Rationale

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

val pool = system.actorOf(SmallestMailboxPool(5).props(Props[Worker]()))
pool ! work    // routed to the worker with the smallest mailbox

Akka's exact selection order:

  1. Routees with 0 messages and no active processing.
  2. Routees with lowest mailbox count (excluding routees actively processing).
  3. Routees that are currently processing but with the smallest backlog.
  4. 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.
 */
export function smallestMailboxStrategy(): RoutingStrategy {
  return (routees, state) => {
    if (routees.length === 0) return [];
    let bestIdx = 0;
    let bestSize = Number.POSITIVE_INFINITY;
    // Iterate in rotation-offset order so ties between equal-depth routees
    // round-robin instead of always picking index 0.
    const offset = state.messageIndex % routees.length;
    for (let i = 0; i < routees.length; i++) {
      const idx = (i + offset) % routees.length;
      const mboxSize = mailboxSizeOf(routees[idx]!);
      if (mboxSize < bestSize) {
        bestSize = mboxSize;
        bestIdx = idx;
        if (bestSize === 0) break;  // can't beat empty
      }
    }
    return [routees[bestIdx]!];
  };
}

function mailboxSizeOf(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).
  const cell = (ref as { _cell?: { mailbox: { size: number } } })._cell;
  return cell?.mailbox.size ?? 0;
}

And the convenience factory in the existing Router object:

export const Router = {
  // ... existing roundRobin / random / broadcast ...

  smallestMailbox<TMsg>(size: number, routeeProps: Props<TMsg>): Props<TMsg | Broadcast<TMsg>> {
    return Props.create(() => new RouterActor({ size, routeeProps, strategy: smallestMailboxStrategy() }));
  },
};

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.
  • Remote routees: mailboxSizeOf returns 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.
  • 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.

Out of scope / non-goals

  • Cluster-aware smallest-mailbox — requires per-routee load metrics over the wire. Track separately (relate to [Feature] Dispatcher-saturation + mailbox-depth histograms #196 dispatcher saturation).
  • 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

  1. 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.
  2. 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.
  3. Tie-break stability: rotating offset (sketch) vs. random vs. strict-lowest-index. Rotating gives best fairness; random adds noise. Recommend: rotating.
  4. 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

  1. Empty pool delivery — 5 routees, all empty; first message goes to routee 0; second to routee 1 (rotation breaks tie).
  2. Unbalanced load — pre-fill routee 0 with 10 messages; send 5 new messages; all 5 go to routees 1-5 (smaller mailboxes).
  3. Single hot routee — routee 0 is processing a slow message (queue grows to 50); next message goes to a different routee, not 0.
  4. All-equal fairness — all routees at exactly depth 3; verify rotation across messages.
  5. Routee death — one routee terminates; router skips it.
  6. Remote routee mailboxmailboxSizeOf returns 0 for RemoteActorRef; route as if empty.
  7. Broadcast still worksrouter.tell(new Broadcast(msg)) bypasses strategy, sends to all.
  8. Stress — 10K messages through 10 routees; verify approximately balanced final mailbox sizes.
  9. Empty pool — 0 routees; tell drops (with warning).
  10. Cross-runtime parity — Bun, Node, Deno.

Acceptance criteria

  • smallestMailboxStrategy() exported from src/Router.ts.
  • Router.smallestMailbox(size, routeeProps) factory exported.
  • Router.smallestMailboxGroup(routees) for the pre-spawned-routees variant.
  • Documentation: "Choosing a router" decision matrix (round-robin vs random vs smallest-mailbox vs scatter-gather).
  • Test suite covers all 10 cases.
  • CHANGELOG entry under "New router: SmallestMailbox".

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: lowNice-to-have / niche / demand-driven

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions