コンテンツにスキップ
日本語

Mailboxes

このコンテンツはまだ日本語訳がありません。

Each actor has exactly one mailbox — a FIFO queue of envelopes waiting to be processed. When you call ref.tell(message), the framework wraps message in an envelope (with sender, log-context, and optional trace context) and enqueues it on the recipient’s mailbox. The dispatcher pulls the next envelope, hands it to the actor’s onReceive, and waits for that to finish before pulling the next one.

That gives every actor the “one message at a time” guarantee — and the mailbox is the thing that makes that physically true.

If you don’t configure anything, the actor gets an unbounded FIFO mailbox. Nothing is ever discarded on the way in; the queue grows until the actor drains it, and the heap is the only ceiling.

Why unbounded? Because the alternative is worse in a way that is easy to miss. Between v0.10 and v0.15 the default was bounded — 10 000 messages, drop-head — and the framework silently discarded the oldest queued message whenever an actor fell behind. The trade was supposed to be “lose some messages, gain a memory ceiling”, and it did not hold up:

  • The ceiling was not real. Only the user queue was bounded. System messages — lifecycle, supervision, watch — were never capped, so a node could still exhaust its heap.
  • A mailbox cannot tell what it is discarding. drop-head suits telemetry, where the freshest reading is the only one that matters. It does not suit a Terminated signal, a delivery confirmation, or a WebSocket close — and those went through the same queue. Each of those turned into a filed defect.

So the loss is now something you ask for, per actor, and the growth is something you are told about: an actor whose queue reaches 10 000 messages logs a warning, and again at each doubling. With metrics enabled, actor_mailbox_size reports the depth per actor.

Reach for a bound when shedding load is better than absorbing it, and you can say which messages are safe to lose:

  • A telemetry or sensor sink where only the newest reading matters. drop-head keeps the queue fresh.
  • An admission point in front of an expensive pipeline, where you would rather refuse work than queue it. drop-new keeps what you already accepted, reject tells the sender to back off.
  • Any actor exposed to a producer you do not control, where an unbounded queue is a denial-of-service surface.
import { ActorOptions } from 'actor-ts';
const sensorOptions = ActorOptions.create()
.withMailboxCapacity(10_000)
.withMailboxOverflow('drop-head');
system.spawn(SensorSink, 'sensors', sensorOptions);

Setting a capacity is what creates the bound; withMailboxOverflow decides which message is lost, and defaults to drop-head. Setting a policy without a capacity is rejected rather than silently ignored — an unbounded mailbox never overflows, so it would have nothing to do.

The three policies and how to choose between them are covered under BoundedMailbox below — including why reject fails the sender rather than the slow actor.

Drops from a capacity you set are counted by actor_mailbox_dropped_total, labelled with the actor’s class, path and the policy that fired.

withMailbox replaces the queue outright — for a PriorityMailbox, or a BoundedMailbox configured beyond what the two options above express, or a subclass of your own:

import { ActorOptions, PriorityMailbox } from 'actor-ts';
const triageOptions = ActorOptions.create()
.withMailbox(() => new PriorityMailbox({ priorityFor: (m) => m.urgency }));
system.spawn(Triage, 'triage', triageOptions);

The mailbox is yours, but its drops are still counted: the cell registers an observer on whatever you return, provided it implements DropReportingMailboxBoundedMailbox does, and a Mailbox subclass of your own can by adding one method:

import { Mailbox, type Envelope, type MailboxDropReason } from 'actor-ts';
class SheddingMailbox<T> extends Mailbox<T> {
private readonly observers: Array<(reason: MailboxDropReason) => void> = [];
observeDrops(observer: (reason: MailboxDropReason) => void): void {
this.observers.push(observer);
}
override enqueue(envelope: Envelope<T>): void {
if (this.shouldShed()) {
for (const observer of this.observers) observer('drop-new');
return;
}
super.enqueue(envelope);
}
}

Registration is additive, so a BoundedMailboxOptions.onDrop of your own keeps firing alongside the stock counter. What you cannot do is combine withMailbox with withMailboxCapacity — that is a configuration error rather than a silent precedence rule.

Inside every mailbox, two queues live side-by-side: user messages (your tells) and system messages (lifecycle signals — create, terminate, failure, watch, …). System messages have absolute precedence: even if 10 000 user messages are queued, the next stop signal or supervisor failure is processed before any of them.

This matters because:

  • Calling ref.stop() does not jump the queue — under the hood it’s a user message (PoisonPill), so the actor first drains the user messages already queued ahead of it, then stops (a graceful drain-then-stop). Only framework-emitted system messages get the absolute precedence above.
  • A failing actor’s supervisor decision (Restart / Resume / Stop) takes effect immediately, not after the queue clears.

You don’t normally see this distinction — system messages are emitted by the framework, not by your code. But understanding it explains why “supervision reacts instantly.”

import { ActorOptions, Actor, ActorSystem, BoundedMailbox } from 'actor-ts';
class SlowConsumer extends Actor<{ kind: 'work'; n: number }> {
override async onReceive(message: { kind: 'work'; n: number }): Promise<void> {
await new Promise(r => setTimeout(r, 100)); // simulate slow work
this.log.info(`processed ${message.n}`);
}
}
const system = ActorSystem.create('demo');
const consumerOptions = ActorOptions.create()
.withMailbox(() => new BoundedMailbox({ capacity: 1_000, overflow: 'drop-head' }));
const consumer = system.spawn(SlowConsumer, 'consumer', consumerOptions);

The mailbox here holds up to 1 000 user messages. When a 1 001st message arrives, the overflow policy decides what happens.

Three policies:

PolicyWhat happens on overflow
'drop-head'Dequeue the oldest message in the queue, discard it, enqueue the new one. Newest messages always make it in.
'drop-new'Discard the incoming message. The old queue is preserved unchanged.
'reject'Throw MailboxFullError at the tell site. Caller surfaces the backpressure.

Which one you get when you name none depends on which door you came through, and the difference is deliberate: constructing a BoundedMailbox yourself defaults to reject, because you built a bound and nothing else can be assumed about what is safe to lose. withMailboxOverflow defaults to drop-head, the policy that suits the workloads people actually reach for a bound to protect. Name the policy either way and the question does not arise.

The options are validated at construction: a missing or non-positive capacity and an unknown overflow policy throw OptionsError.

Picking between them is a backpressure-vs-loss trade-off:

  • drop-head = “freshest wins.” Right for telemetry, sensor data, status pings — where stale messages are worthless and the latest snapshot is the only thing that matters.
  • drop-new = “first wins.” Right for command-streams where re-ordering is unacceptable and dropping a late arrival is OK.
  • reject = “let the sender deal with it.” Right when the sender has a meaningful backoff response (retry, route to a different actor, return 503 from an HTTP handler).

droppedCount on the mailbox instance tracks how many messages have been discarded — useful to wire into a metrics gauge so you notice when the bound is hit.

import { ActorOptions, Actor, ActorSystem, PriorityMailbox } from 'actor-ts';
type Message =
| { readonly kind: 'urgent'; readonly text: string }
| { readonly kind: 'normal'; readonly text: string }
| { readonly kind: 'bulk'; readonly text: string };
class Worker extends Actor<Message> {
override onReceive(message: Message): void {
this.log.info(`[${message.kind}] ${message.text}`);
}
}
const workerOptions = ActorOptions.create<Message>()
.withMailbox(() => new PriorityMailbox<Message>({
priorityFor: (message) => message.kind === 'urgent' ? 0
: message.kind === 'normal' ? 5
: 10,
}));
const worker = system.spawn(Worker, 'worker', workerOptions);
worker.tell({ kind: 'bulk', text: 'batch import row 1' });
worker.tell({ kind: 'normal', text: 'user login' });
worker.tell({ kind: 'urgent', text: 'page-out: disk full' });
// → processed order: urgent → normal → bulk

The priorityFor callback runs at enqueue time, computing a numeric priority per message. Lower numbers go first (priority 0 is highest), and ties break by FIFO insertion order — so two 'normal' messages stay in send-order relative to each other.

Common shapes for priorityFor:

  • Per-kind constant table — like the example above. Easy to read, easy to evolve.
  • Field-derivedpriorityFor: (m) => m.deadlineMs makes earliest-deadline messages run first. Works because both axes are “lower = sooner.”
  • Caller-tagged — sender includes priority: number in the message and priorityFor just reads it. Sometimes the right call; usually a smell that the recipient should derive priority from message content instead.

The current implementation uses a sorted-insertion array — O(log n) locate + O(n) splice on each enqueue. Fine for mailboxes that stay in the low thousands; if you have a sustained 10 000-message backlog where priority insertion shows up in profiles, the mailbox is open to a heap-backed swap (see the source).

Three knobs on ActorOptions:

import { ActorOptions, PriorityMailbox } from 'actor-ts';
// Bound the default FIFO, and say what a full one discards.
const cappedOptions = ActorOptions.create()
.withMailboxCapacity(500)
.withMailboxOverflow('drop-new');
// Full custom factory — pick the type and configure it.
const customOptions = ActorOptions.create()
.withMailbox(() => new PriorityMailbox({ priorityFor: (m) => m.urgency }));

withMailboxCapacity(n) turns the default FIFO into a bounded one; withMailboxOverflow picks the policy, defaulting to drop-head. The policy on its own is rejected — an unbounded mailbox never overflows, so it would be a no-op that reads like configuration.

withMailbox(factory) is the general form — you return a brand-new mailbox instance from the factory. The factory is called once per actor instance (including on restart), so each restarted actor gets a fresh empty mailbox-data-structure. It replaces the queue rather than configuring it, so combining it with withMailboxCapacity is a configuration error rather than a silent precedence rule, and the framework wires no drop telemetry into whatever you return.

There is no system-wide mailbox setting: the default is unbounded and every bound is chosen per actor.

When an actor calls this.context.stash() inside onReceive, the current message is parked. When the actor later calls unstashAll(), the parked messages are re-prepended to the front of the mailbox.

This works the same way for all three mailbox types — the framework calls mailbox.prependUser(envs), and the mailbox decides how to reinsert. Notably for PriorityMailbox, unstashed messages are re-prioritized: a stashed bulk message rejoins the bulk tier, even if you stashed it while urgent messages were arriving. Stash order is preserved within a priority tier.

See Become and stash for the full behavior-switching story.

For most actors, the default unbounded FIFO is right. Reach for an alternative in three situations:

  1. Producer/consumer mismatch. The producer can emit faster than the consumer can drain. Bound the consumer’s mailbox; pick an overflow policy that matches the workload (drop-head for telemetry, reject for HTTP-driven backpressure).
  2. Latency budget per kind. Some messages must be handled in tens of milliseconds (user-facing requests), others can wait minutes (background reconciliation). Priority mailbox; the urgent kind gets 0, the background kind gets 100.
  3. Memory bound. An actor with no application-level priority distinction whose queue will grow without limit if it falls behind — an audit-log subscriber during a spike, or anything fed by a producer you do not control. Bound it at a number that matches your memory budget; drop-head if the latest events are most valuable, drop-new if the ones you already accepted are. Watch actor_mailbox_size to find out whether you need this before you need it.
  • Dispatchers — the scheduler that pulls from the mailbox. Mailbox = the queue; dispatcher = when to drain it.
  • Become and stash — parking messages for later, restoring them via unstashAll.
  • ActoronReceive is what the mailbox delivers messages to.
  • Coordinated shutdown — what happens to pending mailbox messages during graceful shutdown.

The BoundedMailbox and PriorityMailbox API references cover the full settings shape.