Ir al contenido
Español

Mailbox sizing

Esta página aún no está disponible en tu idioma.

The default actor mailbox is unbounded. Nothing is discarded on the way in, and the queue grows until the actor drains it. Bounding one is a per-actor decision, because it is a decision to lose messages — you take it where you can say which messages are safe to lose.

This page is the decision guide for production mailbox sizing.

const ref = system.spawnAnonymous(Worker);
// ↑ unbounded FIFO mailbox — nothing is ever dropped on the way in

Unbounded is the honest default: an actor framework cannot know which of your messages is expendable, and between v0.10 and v0.15 this one guessed. It bounded every mailbox at 10 000 with drop-head, so an actor that fell behind silently lost its oldest queued message — which is right for a sensor reading and wrong for a Terminated signal, a delivery confirmation or a WebSocket close. All three went through the same queue, and each became a filed defect.

The ceiling that trade bought was not real either: only the user queue was bounded. System messages were never capped, so the process could exhaust its heap regardless.

What you get instead is growth you are told about:

  • An actor whose queue reaches 10 000 messages logs a warning, and again at each doubling — 20 000, 40 000, and so on. This is always on and needs no metrics stack.
  • With metrics enabled, actor_mailbox_size{class, path} reports the depth of any mailbox at or above that same mark.

So the failure mode an unbounded queue can still reach — heap exhaustion, long GC pauses, a producer that never learns there is a problem — announces itself well before it arrives. Watch for the warning; bound the actors that produce it.

Three patterns where an unbounded queue is the wrong answer. In each, the capacity and the policy are both deliberate choices:

1. Producer/consumer mismatch known in advance

Section titled “1. Producer/consumer mismatch known in advance”
import { ActorOptions } from 'actor-ts';
// Slow consumer: writes to disk at 10/sec; producer pushes 1000/sec
const writerOptions = ActorOptions.create()
.withMailbox(() => new BoundedMailbox({
capacity: 1_000,
overflow: 'reject',
}));
const slowWriter = system.spawnAnonymous(SlowWriter, writerOptions);

Bound at the worst-case-acceptable buffer. reject propagates backpressure to the sender — they see MailboxFullError and adapt (retry, drop, alert).

2. Telemetry-style actors (stale data is wrong)

Section titled “2. Telemetry-style actors (stale data is wrong)”
import { ActorOptions } from 'actor-ts';
const telemetryOptions = ActorOptions.create()
.withMailbox(() => new BoundedMailbox({
capacity: 5_000,
overflow: 'drop-head',
}));
const telemetry = system.spawnAnonymous(MetricsAggregator, telemetryOptions);

For metrics, sensor readings, status pings — fresher is better. drop-head discards the oldest pending message when new ones arrive, keeping the queue full of recent data.

import { ActorOptions } from 'actor-ts';
const authOptions = ActorOptions.create()
.withMailbox(() => new BoundedMailbox({
capacity: 10_000,
overflow: 'drop-new',
}));
const auth = system.spawnAnonymous(AuthActor, authOptions);

drop-new discards incoming messages when full — preserves already-queued work. Right when “the queue I have is the work I care about” — partial denial of service is preferable to processing nothing.

Three factors:

  1. Worst-case burst size — how many messages arrive in the worst-case window before the consumer can drain.
  2. Per-message memorycapacity × bytes_per_message bounds the memory cost.
  3. Latency budgetcapacity / drain_rate bounds the worst-case latency a message waits before processing.

For a worker processing 100 msg/sec, expecting bursts up to 1000 msg arriving in 1 second:

capacity = 1000 # worst-case burst
worst-case latency = 1000 / 100 = 10s # if fully queued

If 10 seconds of queue is acceptable, capacity 1000 is fine. If not, reduce capacity or accept that producers will see MailboxFullError.

Stock metrics (Stock metrics) expose mailbox depth:

actor_mailbox_size{class="Worker", path="..."}
actor_mailbox_dropped_total{class="Worker", path="...", reason="drop-head"}

Watch:

  • actor_mailbox_size — the depth of any mailbox at or above 10 000 queued messages. A series only exists once an actor crosses that mark, so its presence is the signal; on a healthy system the metric is empty. A drained mailbox reads 0 rather than its last spike.
  • actor_mailbox_dropped_total — non-zero with drop-head / drop-new is by design for the actors you bounded; on any other actor it should not appear at all.
  • MailboxFullError rate at the sender — usually surfaces as supervisor restarts of the sending actor.

The same 10 000 threshold produces a log warning, repeated at each doubling, whether or not metrics are enabled. That is the line to alert on if you run no metrics stack.

PolicyWhen
rejectBackpressure surfaces to sender. Sender must handle.
drop-headTelemetry / metrics — newest wins.
drop-newCritical work — preserve queued, drop incoming.

Pick by what the right answer is on overflow:

  • “Sender should retry / alert” → reject.
  • “Stale data is wrong” → drop-head.
  • “Queued work is precious” → drop-new.

There’s no “best” — context-dependent.

For actors with mixed urgency:

import { ActorOptions, PriorityMailbox } from 'actor-ts';
const workerOptions = ActorOptions.create<Message>()
.withMailbox(() => new PriorityMailbox<Message>({
priorityFor: (m) => m.kind === 'urgent' ? 0 : 5,
}));
const worker = system.spawnAnonymous(Worker, workerOptions);

Lower numbers = higher priority. System messages always trump.

Use for:

  • HTTP responses (urgent) vs batch jobs (deferrable).
  • Health pings vs bulk metrics.

See Mailboxes for the full PriorityMailbox surface.

producer → reject backpressure → sender slows down
producer → drop-head → producer keeps going; reader sees latest
producer → drop-new → producer keeps going; reader processes earliest

Bounded mailboxes are one layer in a backpressure story. For end-to-end backpressure (the upstream system slowing down), you’d combine:

  • Bounded mailbox at the actor.
  • Sender retry logic.
  • Upstream rate-limiting (HTTP 429, broker push-back).

The mailbox enforces the local boundary; the rest is your protocol design.