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.
Die Default-Mailbox
Abschnitt betitelt „Die Default-Mailbox“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-headpasst zu Telemetrie, wo nur der frischeste Messwert zählt. Zu einer Delivery-Bestätigung oder einem WebSocket-closepasst es nicht — und die liefen durch dieselbe Queue. Aus jedem dieser Fälle wurde ein gemeldeter Defekt.Zwei Nachrichten, die das Framework in deinem Namen sendet, sind inzwischen für jede Policy unerreichbar: das
Terminatedder Death Watch und das Kommando, das den Actor einer WebSocket-Verbindung spawnt. Beide nehmen eine Spur, die keine Schranke abwerfen kann (siehe Death Watch und Eine eigene Mailbox weiter unten). Der Test, den sie bestehen: das Framework hat sie gebaut, genau einmal gesendet und nichts behalten, womit es sie erneut senden könnte. Der Rest ist Anwendungsverkehr, und nur du weißt, welcher davon verzichtbar ist.
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 oberhalb dieser Marke, und actor_mailbox_depth die
gesamte Verteilung darunter — ein Ausschlag, der die Warnschwelle nie
erreicht, bleibt damit trotzdem sichtbar.
Eine Queue ohne Decke muss billig bleiben, wenn sie tief wird — deshalb
liegt beiden Queues einer Mailbox ein
RingBuffer zugrunde und kein einfaches
Array: die nächste Nachricht zu entnehmen rückt einen Index vor, statt
alles dahinter neu zu indizieren. Die Kosten, eine Nachricht
auszuliefern, wachsen also nicht mit dem Backlog, der dahinter wartet.
Von außen ist das unsichtbar — auch für eine eigene Mailbox-Subklasse,
die nur die Methoden sieht.
Wann eine Mailbox begrenzen
Abschnitt betitelt „Wann eine Mailbox begrenzen“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-headhält die Queue frisch. - Ein Admission-Punkt vor einer teuren Pipeline, wo du Arbeit
lieber ablehnst als einreihst.
drop-newbehält, was du schon angenommen hast,rejectsagt 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. Lies den dritten Punkt mit den ersten beiden: er sagt, wo ein Bound eine Überlegung wert ist, nicht dass der Verkehr dort verzichtbar wäre. Ein WebSocket-Hub ist der deutlichste Fall von beidem zugleich — siehe Die Mailbox des Hubs begrenzen dazu, was ein Bound dort kostet und was nicht.
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 und der Policy, die
gegriffen hat. Pro Klasse, nicht pro Actor: Abwerfen ist das, wofür
eine beschränkte Mailbox da ist — ein Label pro Actor würde also für
jeden Actor, der sich exakt wie vorgesehen verhält, eine dauerhafte
Metrik-Serie erzeugen. Um zu sehen, welche Instanz abwirft, ergänze ein
eigenes onDrop; es läuft neben dem Stock-Counter statt an seiner
Stelle — und es bekommt den verworfenen Envelope, kann also sagen, was
verloren ging, und nicht nur, dass etwas verloren ging.
Um diesen Envelope zu behalten statt ihn nur im Vorbeigehen anzusehen,
baue die Mailbox mit deadLetterDrops: true: Jede verworfene Nachricht
wird dann zu einem Dead Letter, mit
erhaltenem Sender. Der Schalter ist standardmäßig aus und nur von einer
selbst konstruierten Mailbox aus erreichbar — der Drop läuft auf dem Stack
des Senders, jeden abgeworfenen Envelope zu leiten ist also Arbeit, die
genau dem Burst berechnet wird, den die Schranke billig abfedern sollte.
Siehe Mailbox-Sizing.
Eine eigene Mailbox mitbringen
Abschnitt betitelt „Eine eigene Mailbox mitbringen“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 MailboxDropObserver } from 'actor-ts';
class SheddingMailbox<T> extends Mailbox<T> { private readonly observers: Array<MailboxDropObserver<T>> = []; readonly deadLetterDrops = true;
observeDrops(observer: MailboxDropObserver<T>): void { this.observers.push(observer); }
override enqueue(envelope: Envelope<T>): void { if (this.shouldShed()) { for (const observer of this.observers) observer('drop-new', envelope); return; } super.enqueue(envelope); }}Eine Meldung trägt neben dem Grund auch den Envelope, und
deadLetterDrops sagt, ob die Cell daraus jeweils einen
Dead Letter machen soll. Übergib den
Envelope, den du wirklich verworfen hast — unter einer Policy in
drop-head-Form ist das der herausgeworfene, nicht der angekommene, sonst
benennt jeder Letter eine Nachricht, die noch in der Queue liegt. Beides
ist optional: Lässt du das Feld weg, wird der Drop gezählt und sonst
nichts — genau das, was BoundedMailbox tut, solange du nichts anderes
verlangst.
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.
Eine Pflicht: enqueueSignal
Abschnitt betitelt „Eine Pflicht: enqueueSignal“Eine Queue, die abwirft, hat eine kurze Liste von Dingen, die sie nicht
abwerfen darf. Das Framework kündigt einen Tod genau einmal an — siehe
Death Watch — und übergibt einem
WebSocket-Hub das Kommando, das den Actor einer Verbindung spawnt,
ebenfalls genau einmal; deshalb kommen beide über enqueueSignal statt
über enqueue, und der Vertrag lautet: reihe es ein, was deine
Schranke auch sagt:
class SheddingMailbox<T> extends Mailbox<T> { // ...wie oben...
/** Absichtlich an der Schranke vorbei: dieses eine ist nicht erneut sendbar. */ override enqueueSignal(envelope: Envelope<T>): void { super.enqueue(envelope); }}Die Basisimplementierung delegiert an dein enqueue, was für eine Queue
richtig ist, die nichts verwirft, und für eine, die es tut, falsch —
also überschreibe sie immer dann, wenn du enqueue zum Abwerfen
überschreibst. BoundedMailbox und PriorityMailbox tun das schon.
Dass die Basis delegiert statt direkt in die Basis-Queue zu schreiben,
ist Absicht: eine Subklasse kann ihre Nachrichten ganz woanders halten,
und ein Envelope, das in einem Speicher versteckt liegt, den dein
dequeueUser nie liest, ist schlimmer als eines, das du verworfen hast.
Das Envelope trägt außerdem undroppable: true, was removeOldest
liest — eine Schranke in drop-head-Form, die auf dieser Nahtstelle
aufsetzt, überspringt es damit gratis und muss nicht wissen, warum.
Wirft dein enqueue stattdessen, macht das Framework aus der
Verweigerung ein Dead Letter und der
Abbau des sterbenden Actors läuft weiter; was es nicht kann, ist eine
zweite Kopie der Benachrichtigung erfinden.
System-Nachrichten kommen immer zuerst
Abschnitt betitelt „System-Nachrichten kommen immer zuerst“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.
- Ein
Terminatedder Death Watch ist ebenfalls eine User-Nachricht, aus demselben Grund wiePoisonPill: es muss nach allem ankommen, was dem Watcher bereits gesagt wurde, damit ein Handler, der auf einen Tod reagiert, die Arbeit davor gesehen hat. Nicht verwerfbar heißt nicht vordrängeln — es behält seinen Platz in der Reihe und kann nur nicht mehr daraus entfernt werden.
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.”
BoundedMailbox
Abschnitt betitelt „BoundedMailbox“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:
| Policy | Was 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.
capacity begrenzt die Nachrichten, die die Mailbox verwerfen darf —
in einer Mailbox, die nur noch nicht zugestellte Todesmeldungen enthält,
liegt die Queue also darüber, statt eine davon zu verlieren. Der
Überschuss ist durch die Zahl der beobachteten Actors begrenzt und ist
der Preis der Ausnahme, die Death Watch
beschreibt: bemiss eine Kapazität an deinem Verkehr, nicht aufs Byte.
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.
PriorityMailbox
Abschnitt betitelt „PriorityMailbox“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 → bulkDer 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-abgeleitet —
priorityFor: (m) => m.deadlineMslässt Nachrichten mit frühesten Deadlines zuerst laufen. Funktioniert, weil beide Achsen “niedriger = früher” sind. - Caller-getagged — der Sender inkludiert
priority: numberin der Nachricht, undpriorityForliest es einfach. Manchmal der richtige Anruf; meist ein Smell, dass der Empfänger die Priorität stattdessen aus dem Nachrichteninhalt ableiten sollte.
Wenn priorityFor nicht antworten kann
Abschnitt betitelt „Wenn priorityFor nicht antworten kann“Dein Callback muss nicht total sein. Wenn er wirft oder mit etwas
antwortet, das keine brauchbare Zahl ist — undefined, NaN, ein Wert
eines anderen Typs — landet die Nachricht mit der niedrigsten
Priorität in der Queue, und mehr passiert nicht. Sie wird nicht
verworfen, und der Fehlschlag taucht nicht an der tell-Stelle auf.
Beide Hälften sind wichtig:
- Niedrigste, nicht höchste. Eine Priorität, die nicht bestimmbar war, darf keine überholen, die angegeben wurde. Gleichstände brechen weiterhin nach FIFO, unrangierbare Nachrichten bleiben also zueinander in Sende-Reihenfolge.
- Eingefangen, nicht geworfen.
priorityForläuft synchron innerhalb destelldes Senders. Der Sender ist ein Unbeteiligter der Mailbox-Konfiguration des Empfängers: er kann gegen ein defektespriorityFornichts tun und kann es nicht von einem eigenen Fehler unterscheiden. Ein entkommender Throw landete imonReceivedes Senders und ließ den Sender neu starten — dieselbe Verwirrung, die diereject-Warnung oben beschreibt, für einen Bug, der mit dem Sender überhaupt nichts zu tun hat.
±Infinity ist eine Rangfolge, kein Fehlschlag: -Infinity bedeutet
“vor allem anderen”, +Infinity bedeutet “hinter allem anderen”, und
beide werden so geordnet, wie sie geschrieben sind. Nur NaN und
Nicht-Zahlen gelten als unbestimmbar.
Um einen eingefangenen Fehlschlag zu sehen und nicht nur seine Folgen,
übergib onPriorityError. Nichts anderes im System meldet einen — die
Nachricht kommt weiterhin an, nur zuletzt:
import { PriorityMailbox, PriorityMailboxOptions } from 'actor-ts';
const triageOptions = PriorityMailboxOptions.create<Message>() .withPriorityFor((message) => message.kind === 'urgent' ? 0 : 10) .withOnPriorityError((cause, message) => { log.warn(`priorityFor could not rank ${JSON.stringify(message)}: ${String(cause)}`); });
const mailbox = new PriorityMailbox<Message>(triageOptions);cause ist, was priorityFor geworfen hat, oder ein TypeError, der
den zurückgegebenen Wert beschreibt. Aufzeichnen und zurückkehren
im Hook: er läuft ebenfalls auf dem Stack des Senders, ein Throw aus ihm
heraus setzt das Entkommen also wieder ein — und kostet die Nachricht
ihren Platz in der Queue.
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).
Eine Priority-Mailbox begrenzen
Abschnitt betitelt „Eine Priority-Mailbox begrenzen“Eine Priority-Mailbox ist unbounded, solange du ihr keine capacity
gibst — genau wie jede andere Mailbox. Der Bound liegt auf den Options
der Mailbox selbst und nicht auf ActorOptions, weil withMailbox und
withMailboxCapacity sich nicht kombinieren lassen: eine übergebene
Mailbox bringt ihren eigenen Bound mit:
import { ActorOptions, PriorityMailbox, PriorityMailboxOptions } from 'actor-ts';
const triageOptions = PriorityMailboxOptions.create<Message>() .withPriorityFor((message) => message.kind === 'urgent' ? 0 : 10) .withCapacity(10_000) .withOverflow('drop-lowest-priority');
const workerOptions = ActorOptions.create<Message>() .withMailbox(() => new PriorityMailbox<Message>(triageOptions));Die Policies sind 'drop-lowest-priority', 'drop-new' und 'reject',
und reject bekommst du, wenn du eine Capacity ohne Policy nennst — eine
Capacity zu nennen sagt, wie viel du hältst, nicht was du bereit bist zu
verlieren.
| Policy | Was bei Overflow passiert |
|---|---|
'drop-lowest-priority' | Verwirft die Nachricht, die am weitesten von der Auslieferung entfernt ist — die mit der niedrigsten Priorität, unter Gleichen die zuletzt angekommene. Die eingehende Nachricht tritt zu denselben Bedingungen an: eine, die unter dem gesamten Backlog rangiert, ist selbst die, die geht. |
'drop-new' | Verwirft die eingehende Nachricht, egal welche Priorität sie bekommen hat. |
'reject' | Wirft MailboxFullError an der tell-Stelle. |
drop-head gibt es hier bewusst nicht. Auf einer FIFO-Queue heißt
es “verwirf die abgestandenste”, und das trägt, weil Ankunftsreihenfolge
dort die einzige Ordnung ist. Auf einer Priority-Queue ist der Kopf die
Nachricht, die dein priorityFor als wichtigste bezeichnet hat — sie zu
verwerfen würde den Grund zunichtemachen, aus dem du diese Mailbox
gewählt hast. Drops werden weiterhin als drop-head an
actor_mailbox_dropped_total gemeldet (dieses Label hat ein
geschlossenes Zwei-Werte-Vokabular), außer wenn die verworfene Nachricht
die eingehende war — dann lautet der Grund drop-new.
Die Options werden bei der Konstruktion validiert: ein fehlendes oder
nicht aufrufbares priorityFor, eine nicht-positive capacity, eine
unbekannte Policy oder eine Policy ohne Capacity werfen alle
OptionsError. Was der Callback zurückgibt, ist eine Laufzeitfrage
und wird stattdessen beim Enqueue behandelt — siehe
Wenn priorityFor nicht antworten kann.
Auf einer begrenzten Mailbox treffen sich beide: eine unrangierbare
Nachricht ist das Unwichtigste in der Queue, unter
drop-lowest-priority wird also die Ankunft abgeworfen und als
drop-new gemeldet.
Per-Actor-Mailbox via ActorOptions
Abschnitt betitelt „Per-Actor-Mailbox via ActorOptions“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.
Mailboxes + Stash
Abschnitt betitelt „Mailboxes + Stash“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.
Ein Bound gilt auch für das Replay. Welche begrenzte Mailbox du
auch gewählt hast: unstashAll() unterliegt der Capacity und der
Overflow-Policy, die du gesetzt hast — ein Replay ist Traffic, der
wieder in die Queue eintritt, keine Hintertür daran vorbei.
Unterschiedlich ist nur, woher der Platz kommt: PriorityMailbox
re-priorisiert, und BoundedMailbox setzt das Replay an die Front und
schafft am Ende der Queue Platz — eine volle Mailbox verliert also ihre
neuesten Nachrichten statt der bewusst geparkten. Unter reject geht
gar nichts verloren: unstashAll() wirft MailboxFullError, der
gesamte Batch bleibt gestasht, und der Fehler erreicht den Supervisor
deines Actors wie jeder andere. Dimensioniere die Capacity für den
Backlog plus das, was du zu stashen erwartest — oder nimm die Drops
als Preis des Bounds in Kauf.
Siehe Become und Stash für die volle Behavior-Switching-Geschichte.
Wann die Mailbox-Wahl wichtig ist
Abschnitt betitelt „Wann die Mailbox-Wahl wichtig ist“Für die meisten Actors ist die unbounded Default-FIFO richtig. Greife in drei Situationen zu einer Alternative:
- 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).
- 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 bekommt100. - 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. Beobachteactor_mailbox_wait_seconds, um das herauszufinden, bevor du es brauchst — sie steigt, sobald ein Actor nicht mehr mitkommt, währendactor_mailbox_sizeerst meldet, wenn eine Queue 10 000 Nachrichten überschreitet. Das p99 vonactor_mailbox_depthist das, woraus du die Zahl selbst wählst: Eine Kapazität ist eine Wette auf den schlimmsten Ausschlag, und dieses Histogramm ist die Messung davon.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- 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
unstashAllwiederherstellen. - Actor —
onReceiveist 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.
