Zum Inhalt springen
Deutsch

Mailboxes

Jeder Actor hat genau eine Mailbox — eine FIFO-Queue von Envelopes, die auf Verarbeitung warten. Wenn du ref.tell(message) aufrufst, wickelt das Framework message in ein Envelope (mit Sender, Log-Kontext und optionalem Trace-Kontext) und reiht es in die Mailbox des Empfängers ein. Der Dispatcher zieht das nächste Envelope, übergibt es dem onReceive des Actors und wartet, bis das fertig ist, bevor er das nächste zieht.

Das gibt jedem Actor die “eine Nachricht nach der anderen”-Garantie — und die Mailbox ist das, was das physikalisch wahr macht.

Wenn du nichts konfigurierst, bekommt der Actor eine unbounded FIFO-Mailbox. Auf dem Weg hinein wird nie etwas verworfen; die Queue wächst, bis der Actor sie abarbeitet, und der Heap ist die einzige Decke.

Warum unbounded? Weil die Alternative auf eine leicht zu übersehende Weise schlechter ist. Zwischen v0.10 und v0.15 war der Default bounded — 10 000 Nachrichten, drop-head — und das Framework verwarf still die älteste wartende Nachricht, sobald ein Actor zurückfiel. Der Handel sollte lauten “ein paar Nachrichten verlieren, dafür eine Speicherdecke gewinnen”, und er ging nicht auf:

  • Die Decke gab es nie. Nur die User-Queue war begrenzt. System-Nachrichten — Lifecycle, Supervision, Watch — waren nie gedeckelt, ein Node konnte den Heap also trotzdem ausschöpfen.
  • Eine Mailbox weiß nicht, was sie verwirft. drop-head passt zu Telemetrie, wo nur der frischeste Messwert zählt. Zu einem Terminated-Signal, einer Delivery-Bestätigung oder einem WebSocket-close passt es nicht — und die liefen durch dieselbe Queue. Aus jedem dieser Fälle wurde ein gemeldeter Defekt.

Der Verlust ist jetzt also etwas, das du pro Actor anforderst, und das Wachstum etwas, von dem du erfährst: ein Actor, dessen Queue 10 000 Nachrichten erreicht, loggt eine Warnung — und erneut bei jeder Verdopplung. Mit aktivierten Metriken meldet actor_mailbox_size die Tiefe pro Actor.

Greif zu einem Bound, wenn Last abwerfen besser ist als Last aufnehmen, und du sagen kannst, welche Nachrichten verzichtbar sind:

  • Eine Telemetrie- oder Sensor-Senke, bei der nur der neueste Messwert zählt. drop-head hält die Queue frisch.
  • Ein Admission-Punkt vor einer teuren Pipeline, wo du Arbeit lieber ablehnst als einreihst. drop-new behält, was du schon angenommen hast, reject sagt dem Sender, er soll zurückstecken.
  • Jeder Actor, der einem Producer ausgesetzt ist, den du nicht kontrollierst — dort ist eine unbegrenzte Queue eine Denial-of-Service-Fläche.
import { ActorOptions } from 'actor-ts';
const sensorOptions = ActorOptions.create()
.withMailboxCapacity(10_000)
.withMailboxOverflow('drop-head');
system.spawn(SensorSink, 'sensors', sensorOptions);

Die Kapazität ist das, was den Bound erzeugt; withMailboxOverflow entscheidet, welche Nachricht verloren geht, und steht per Default auf drop-head. Eine Policy ohne Kapazität wird abgelehnt statt still ignoriert — eine unbounded Mailbox läuft nie über, hätte also nichts zu tun.

Die drei Policies und wie du zwischen ihnen wählst, stehen unten unter BoundedMailbox — inklusive der Frage, warum reject den Sender scheitern lässt und nicht den langsamen Actor.

Drops aus einer Kapazität, die du gesetzt hast, zählt actor_mailbox_dropped_total, gelabelt mit Klasse, Pfad und der Policy, die gegriffen hat.

withMailbox ersetzt die Queue vollständig — für eine PriorityMailbox, für eine BoundedMailbox jenseits dessen, was die zwei Optionen oben ausdrücken, oder für eine eigene Subklasse:

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

Die Mailbox gehört dir, ihre Drops werden trotzdem gezählt: die Cell registriert einen Observer auf dem, was du zurückgibst — sofern es DropReportingMailbox implementiert. BoundedMailbox tut das, und eine eigene Mailbox-Subklasse kann es mit einer Methode:

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);
}
}

Das Registrieren ist additiv, ein eigenes BoundedMailboxOptions.onDrop feuert also weiter neben dem Standard-Counter. Was nicht geht, ist withMailbox mit withMailboxCapacity zu kombinieren — das ist ein Konfigurationsfehler, keine stille Vorrangregel.

In jeder Mailbox leben zwei Queues nebeneinander: User-Nachrichten (deine tells) und System-Nachrichten (Lifecycle-Signale — Create, Terminate, Failure, Watch, …). System-Nachrichten haben absoluten Vorrang: selbst wenn 10.000 User-Nachrichten in der Queue stehen, wird das nächste stop-Signal oder die Supervisor-failure vor allen verarbeitet.

Das ist wichtig, weil:

  • ref.stop() aufzurufen springt nicht in der Queue vor — unter der Haube ist es eine User-Nachricht (PoisonPill), der Actor drained also erst die User-Nachrichten, die bereits davor in der Queue stehen, und stoppt dann (ein sauberes Drain-dann-Stop). Nur vom Framework emittierte System-Nachrichten bekommen den absoluten Vorrang von oben.
  • Die Supervisor-Entscheidung eines fehlschlagenden Actors (Restart / Resume / Stop) tritt sofort in Kraft, nicht erst nachdem die Queue leer ist.

Du siehst diese Unterscheidung normalerweise nicht — System-Nachrichten werden vom Framework emittiert, nicht von deinem Code. Aber sie zu verstehen, erklärt, warum “Supervision sofort reagiert.”

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)); // langsame Arbeit simulieren
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,
);

Die Mailbox hier hält bis zu 1.000 User-Nachrichten. Wenn eine 1.001-te Nachricht ankommt, entscheidet die Overflow-Policy, was passiert.

Drei Policies:

PolicyWas bei Overflow passiert
'drop-head'Dequeue die älteste Nachricht in der Queue, verwirf sie, reihe die neue ein. Neueste Nachrichten kommen immer rein.
'drop-new'Verwirf die eingehende Nachricht. Die alte Queue bleibt unverändert.
'reject'Wirf MailboxFullError an der tell-Stelle. Der Aufrufer trägt den Backpressure.

Welche du bekommst, wenn du keine nennst, hängt davon ab, durch welche Tür du gekommen bist — und der Unterschied ist Absicht: eine BoundedMailbox selbst zu konstruieren steht per Default auf reject, weil du einen Bound gebaut hast und sonst nichts darüber angenommen werden kann, was verzichtbar ist. withMailboxOverflow steht per Default auf drop-head, der Policy, die zu den Workloads passt, für die man tatsächlich zu einem Bound greift. Nennst du die Policy, stellt sich die Frage gar nicht.

Die Optionen werden bei der Konstruktion validiert: eine fehlende oder nicht-positive capacity und eine unbekannte Overflow-Policy werfen OptionsError.

Die Wahl zwischen ihnen ist ein Backpressure-vs-Verlust-Trade-off:

  • drop-head = “Frisch gewinnt.” Richtig für Telemetrie, Sensor-Daten, Status-Pings — wo veraltete Nachrichten wertlos sind und nur der letzte Snapshot zählt.
  • drop-new = “Erst gewinnt.” Richtig für Command-Streams, bei denen Umordnung inakzeptabel ist und das Verwerfen einer späten Ankunft okay ist.
  • reject = “Lass den Sender damit umgehen.” Richtig, wenn der Sender eine sinnvolle Backoff-Antwort hat (Retry, an einen anderen Actor routen, 503 aus einem HTTP-Handler zurückgeben).

droppedCount auf der Mailbox-Instanz verfolgt, wie viele Nachrichten verworfen wurden — nützlich, um es in eine Metrik-Gauge zu verdrahten, damit du es bemerkst, wenn das Limit getroffen wird.

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' });
// → Verarbeitungs-Reihenfolge: urgent → normal → bulk

Der priorityFor-Callback läuft zur Enqueue-Zeit und berechnet eine numerische Priorität pro Nachricht. Niedrigere Zahlen gehen zuerst (Priorität 0 ist am höchsten), und Gleichstände werden nach FIFO-Insertion-Reihenfolge gebrochen — zwei 'normal'-Nachrichten bleiben also in Sende-Reihenfolge zueinander.

Häufige Formen für priorityFor:

  • Per-kind-Konstantentabelle — wie das Beispiel oben. Einfach zu lesen, einfach zu evolvieren.
  • Feld-abgeleitetpriorityFor: (m) => m.deadlineMs lässt Nachrichten mit frühesten Deadlines zuerst laufen. Funktioniert, weil beide Achsen “niedriger = früher” sind.
  • Caller-getagged — der Sender inkludiert priority: number in der Nachricht, und priorityFor liest es einfach. Manchmal der richtige Anruf; meist ein Smell, dass der Empfänger die Priorität stattdessen aus dem Nachrichteninhalt ableiten sollte.

Die aktuelle Implementierung verwendet ein sorted-insertion-Array — O(log n) Locate + O(n) Splice bei jedem Enqueue. In Ordnung für Mailboxes, die in den niedrigen Tausenden bleiben; wenn du einen nachhaltigen 10.000-Nachrichten-Backlog hast, bei dem Priority-Insertion in Profilen auftaucht, ist die Mailbox offen für einen Heap-backed-Swap (siehe den Source).

Drei Knöpfe auf ActorOptions:

import { ActorOptions, PriorityMailbox } from 'actor-ts';
// Begrenze das Default-FIFO und sag, was eine volle Mailbox verwirft.
const cappedOptions = ActorOptions.create()
.withMailboxCapacity(500)
.withMailboxOverflow('drop-new');
// Volle eigene Factory — wähle den Typ und konfiguriere ihn.
const customOptions = ActorOptions.create()
.withMailbox(() => new PriorityMailbox({ priorityFor: (m) => m.urgency }));

withMailboxCapacity(n) macht aus dem Default-FIFO ein begrenztes; withMailboxOverflow wählt die Policy, per Default drop-head. Die Policy allein wird abgelehnt — eine unbounded Mailbox läuft nie über, das wäre ein No-op, das wie Konfiguration aussieht.

withMailbox(factory) ist die allgemeine Form — du gibst eine brandneue Mailbox-Instanz aus der Factory zurück. Die Factory wird einmal pro Actor-Instanz aufgerufen (inklusive bei Restart), jeder neu gestartete Actor bekommt also eine frische, leere Mailbox-Datenstruktur. Sie ersetzt die Queue, statt sie zu konfigurieren: mit withMailboxCapacity kombiniert ist das ein Konfigurationsfehler statt einer stillen Vorrangregel, und das Framework verdrahtet keine Drop-Telemetrie in das, was du zurückgibst.

Es gibt keine systemweite Mailbox-Einstellung: der Default ist unbounded, und jeder Bound wird pro Actor gewählt.

Wenn ein Actor this.context.stash() innerhalb von onReceive aufruft, wird die aktuelle Nachricht geparkt. Wenn der Actor später unstashAll() aufruft, werden die geparkten Nachrichten an die Front der Mailbox re-prepended.

Das funktioniert für alle drei Mailbox-Typen gleich — das Framework ruft mailbox.prependUser(envs), und die Mailbox entscheidet, wie sie re-inserted. Besonders bei PriorityMailbox: unstashed Nachrichten werden re-priorisiert: eine gestashte bulk-Nachricht reiht sich wieder in die bulk-Schicht ein, selbst wenn du sie gestasht hast, während dringende Nachrichten ankamen. Stash-Reihenfolge wird innerhalb einer Priority-Schicht bewahrt.

Siehe Become und Stash für die volle Behavior-Switching-Geschichte.

Für die meisten Actors ist die unbounded Default-FIFO richtig. Greife in drei Situationen zu einer Alternative:

  1. Producer/Consumer-Mismatch. Der Producer kann schneller emittieren, als der Consumer drainen kann. Begrenze die Mailbox des Consumers; wähle eine Overflow-Policy, die zur Workload passt (drop-head für Telemetrie, reject für HTTP-getriebenen Backpressure).
  2. Latenz-Budget pro Kind. Manche Nachrichten müssen in zehn Millisekunden behandelt werden (user-facing Requests), andere können Minuten warten (Hintergrund-Reconciliation). Priority-Mailbox; die dringende Sorte bekommt 0, die Hintergrund-Sorte bekommt 100.
  3. Memory-Bound. Ein Actor ohne Anwendungs-Level-Prioritäts-Unterscheidung, dessen Queue ohne Limit wächst, wenn er zurückfällt — ein Audit-Log-Subscriber bei einer Spitze, oder alles, was von einem Producer gespeist wird, den du nicht kontrollierst. Begrenze ihn auf eine Zahl, die zu deinem Memory-Budget passt; drop-head, wenn die neuesten Events am wertvollsten sind, drop-new, wenn es die schon angenommenen sind. Beobachte actor_mailbox_size, um das herauszufinden, bevor du es brauchst.
  • Dispatcher — der Scheduler, der aus der Mailbox zieht. Mailbox = die Queue; Dispatcher = wann zu drainen ist.
  • Become und Stash — Nachrichten für später parken, sie via unstashAll wiederherstellen.
  • ActoronReceive ist das, wohin die Mailbox Nachrichten ausliefert.
  • Coordinated Shutdown — was mit ausstehenden Mailbox-Nachrichten während des sauberen Shutdowns passiert.

Die BoundedMailbox- und PriorityMailbox-API-Referenzen decken die volle Settings-Form ab.