Skip to content

[Bug] BoundedMailbox.enqueue builds a ts-pattern matcher and three closures per dropped message, so the overflow path costs about 19x an inlined switch exactly when the system is already saturated #974

Description

@pathosDev

Problem

BoundedMailbox.enqueue dispatches its overflow policy through match(this.overflow) with three .with() arms and an .exhaustive(). That expression is evaluated per dropped message: a fresh ts-pattern matcher object, three arrow closures (each capturing this and env), and a walk of the arm list — all constructed before any of them is selected, and all garbage immediately after.

The path this sits on is the one that runs when the system is already saturated. A mailbox at capacity is, by definition, a producer outrunning a consumer; the drop path is what the framework does instead of keeping up, and making it the most expensive branch in the class is backwards. Under a burst every excess message pays the matcher, so the cost scales with exactly the thing that is already going wrong.

The value being matched is a three-member string union stored in a readonly field, set once in the constructor. It never changes, it is not a message, and it carries no payload — so this is not a case AGENTS.md's match-per-arm rule is aimed at (that rule is about incoming messages, events and commands; a policy field read in a hot loop is precisely the "computes a value / internal state" exemption). The repo already made this call once, explicitly, in its own benchmark suite.

Evidence

The call site:

src/mailbox/BoundedMailbox.ts:40-65
  override enqueue(env: Envelope<T>): void {
    if (this.size >= this.capacity) {
      match(this.overflow)
        .with('drop-head', () => {
          // `removeOldest` rather than `dequeueUser`: the latter returns
          // undefined while the mailbox is suspended, which used to make this
          // whole arm a no-op — the queue grew past capacity and the drop was
          // reported anyway.  Counting is gated on an actual removal so the
          // metric cannot claim a drop that did not happen.
          const dropped = super.removeOldest();
          if (dropped !== undefined) {
            this.droppedCount++;
            this.onDrop?.('drop-head');
          }
          super.enqueue(env);
        })
        .with('drop-new', () => {
          this.droppedCount++;
          this.onDrop?.('drop-new');
        })
        .with('reject', () => { throw new MailboxFullError(this.capacity); })
        .exhaustive();
      return;
    }
    super.enqueue(env);
  }

The field it matches on — assigned once, never reassigned:

src/mailbox/BoundedMailbox.ts:24-38
export class BoundedMailbox<T = unknown> extends Mailbox<T> {
  private readonly capacity: number;
  private readonly overflow: BoundedMailboxOverflow;
  private readonly onDrop?: (reason: 'drop-head' | 'drop-new') => void;
  /** Number of messages dropped by the overflow policy — useful for metrics. */
  droppedCount = 0;

  constructor(options: BoundedMailboxOptions) {
    super();
    const settings = { ...(options as Partial<BoundedMailboxOptionsType>) };
    new BoundedMailboxOptionsValidator().validate(settings);
    this.capacity = settings.capacity!;
    this.overflow = settings.overflow ?? 'reject';
    this.onDrop = settings.onDrop;
  }

The project's own measurement of the same library on a per-message path, and the exemption it already took:

benchmarks/single-node/become-unbecome.ts:11-19
/*
 * The dispatch below deliberately stays a raw `if`-chain, against the
 * project-wide `match()` rule (AGENTS.md).  This benchmark measures the
 * per-message path itself, and ts-pattern's allocation per `match()` call
 * shows up directly in the number: converting it cost ~10 % here
 * (57k -> 51k swap/s), consistently across alternating runs.
 * Measuring the framework's overhead through a matcher that production
 * actor code would amortise differently makes the figure say less, not more.
 */

This is the default mailbox for every actor since #310, so the drop path is not a niche configuration:

src/util/Constants.ts:93-93
export const DEFAULT_MAILBOX_CAPACITY = 10_000;
src/util/Constants.ts:105-105
export const DEFAULT_MAILBOX_OVERFLOW = 'drop-head' as const;

Proposal

  • Replace the match with a switch (this.overflow) over the same three literals and default: unreachable, or hoist the decision out of the hot path entirely: resolve the policy once in the constructor into a private onOverflow method reference (this.onOverflow = this.dropHead) and call it. The second shape keeps each arm a named private method, which reads better than the inline arrow bodies do today and is closer to the spirit of the per-arm rule than the current code is.
  • Note the exemption explicitly in a comment, the way become-unbecome.ts does, so the next sweep does not convert it back.
  • The drop-new arm is where the ratio is worst, because the arm body itself is two statements — there the matcher is the work.
  • Consider the same treatment for any other match that sits on a per-message path; this one is called out because it is the default mailbox and the saturated path.

Acceptance sketch

  • BoundedMailbox.enqueue performs no per-message matcher or closure allocation on the overflow path.
  • All three policies behave identically — drop-head still gates its counter on an actual removal, drop-new still counts and returns, reject still throws MailboxFullError; existing MailboxVariants tests stay green.
  • A comment records why the matcher is not used here, referencing the benchmark note.
  • A benchmark row covers the saturated enqueue path so a regression is visible.

Adjacent issues: #408 (ring-buffer backing store to remove Array.shift()) touches the same method's drop-head arm — removeOldest() is the shift() in question, and it dominates that arm's cost today, so the two fixes compound rather than overlap. #773 (dropped messages are never dead-lettered) and #772 (prependUser bypasses the capacity check) are correctness findings in the same class. #647 (bounded PriorityMailbox) and #862 (default-mailbox config block) are the same default from other angles.

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution — with the caveat that the timing half is indicative, since other work was running on the machine. The real BoundedMailbox was driven against a local subclass with identical semantics dispatched by switch, both pre-filled to capacity and warmed, best of three alternating rounds:

overflow='drop-new'   2.000.000 overflowing enqueues  ts-pattern 486 ms  vs  switch  25 ms  -> 19.37x
overflow='drop-head'  2.000.000 overflowing enqueues  ts-pattern 491 ms  vs  switch 161 ms  ->  3.05x

drop-new isolates the matcher (the arm body is two statements) — ~243 ns versus ~12 ns per dropped message. drop-head shows a smaller ratio because removeOldest()'s Array.shift() dominates there, which is #408's subject.

The structural half needs no measurement and is machine-independent: one matcher object, three arrow closures and one .exhaustive() walk are constructed per call by the language's evaluation rules, regardless of which arm wins.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions