Zum Inhalt springen
Deutsch

Dead Letters

Ein Dead Letter ist eine Nachricht, die das System nicht zustellen konnte: ein tell an eine Ref, deren Actor gestoppt ist, eine Selection, die ins Leere lief, ein Behavior, das mit unhandled geantwortet hat, ein Cluster-Singleton ohne Host. Nichts ist aus Versehen verloren gegangen — das Framework hat es bemerkt, und so sagt es das.

Jedes Dead Letter wird in ein DeadLetter verpackt und auf dem Event-Stream veröffentlicht. Das ist das gesamte Standardverhalten.

Insbesondere loggt das Framework Dead Letters nicht. DeadLetterRef hält keinen Logger; wenn niemand den Stream abonniert, ist das Letter weg. Für einen Entwicklungslauf ist das in Ordnung und eine schlechte Antwort auf „was haben wir während des Vorfalls letzte Nacht verworfen?“ — genau dafür gibt es die Queue weiter unten.

import { DeadLetter } from 'actor-ts';
class Watcher extends Actor<DeadLetter> {
override preStart(): void {
this.system.eventStream.subscribe(this.self, DeadLetter);
}
override onReceive(letter: DeadLetter): void {
this.system.log.warn(`undelivered: ${letter.message} -> ${letter.recipient.path}`);
}
}

Jedes Dead Letter nennt seinen Empfänger — den Actor, den die Nachricht nicht erreicht hat, nicht das Dead-Letter-Büro. Das macht ein Letter von einem anderen unterscheidbar, und darauf bauen die Filter und das erneute Zustellen weiter unten auf.

system.deadLetterQueue existiert immer und bewahrt standardmäßig nichts auf. Vier Stores, eine Achse — wie viel vom Letter aufbewahrt wird:

storeBewahrt aufÜbersteht einen Neustart
off (Standard)nichts
metricseinen Counter, keine Payload
memoryeinen begrenzten Ringnein
persistentdenselben Ring, plus ein Append-only-Log im Journalja

metrics gibt es, weil eine Payload aufzubewahren eine andere Entscheidung ist, als eine Rate zu beobachten. Ein Ring hält eine starke Referenz auf jede unzustellbare Nachricht — eine Datenschutzfrage, sobald eine Payload etwas über eine Person aussagt; ein Counter nicht. list() bleibt leer und replay antwortet für jede id mit unknown-entry: es wurde nichts aufbewahrt, es gibt also nichts zurückzugeben. Mit einem winzigen Ring lässt sich das nicht annähern — max-entries muss positiv sein, also bewahrt selbst der kleinste eine lebende Payload für ein ganzes Retention-Fenster auf.

actor-ts.dead-letters {
store = "memory"
max-entries = 1000
retention = 1h
max-replays = 3
}

Dieselben Stellschrauben gibt es als Options-Familie, die dem System bei seiner Erzeugung übergeben wird:

import { ActorSystem, ActorSystemOptions, DeadLetterQueueOptions } from 'actor-ts';
const deadLetterOptions = DeadLetterQueueOptions.create()
.withStore('persistent')
.withMaxEntries(5_000)
.withRetentionMs(6 * 60 * 60 * 1_000);
const systemOptions = ActorSystemOptions.create()
.withDeadLetters(deadLetterOptions);
const system = ActorSystem.create('orders', systemOptions);

Die Vorrangregel ist die übliche — explizite Options schlagen HOCON schlagen die eingebauten Standardwerte — und sie gilt feldweise: eine im Code benannte Stellschraube lässt den Rest des Blocks actor-ts.dead-letters in Kraft.

Jeder Key in diesem Block entscheidet, was aufbewahrt wird — deshalb liegen sie unter actor-ts.dead-letters und nicht neben den künftigen Stellschrauben für Logging und Rate-Limiting: Die entscheiden, wie laut ein Letter angekündigt wird, werden auf dem Publish-Pfad gelesen und nicht von der Queue, und sitzen bewusst hinter dem Erfassen — eine Unterdrückungseinstellung kann diese Aufzeichnung also nie still unvollständig machen.

Die Queue hat auch ein Panel: Sobald sie eingeschaltet ist, zeigt der Dead-Letter-Inspektor in DevTools dieselben Einträge im Browser, samt Payloads. Im Code:

list() liefert Einträge neueste zuerst, eingeengt durch einen optionalen Filter. Alles wird awaited, weil eine persistent-Queue noch das Log eines vorherigen Laufs zurücklesen kann.

const recent = await system.deadLetterQueue.list({
recipient: 'actor-ts://orders/user/checkout',
sinceMs: Date.now() - 15 * 60 * 1_000,
limit: 20,
});
for (const entry of recent) {
console.log(entry.id, entry.timestampMs, entry.recipientPath, entry.payload);
}

recipient trifft einen Pfad oder seinen Teilbaum, sodass actor-ts://orders/user alles auswählt, was die Anwendung gespawnt hat.

Das payload eines Eintrags hat eine von zwei Formen:

  • { kind: 'captured', message } — die Originalnachricht, unverändert.
  • { kind: 'degraded', className, reason } — die Payload konnte nicht ins Journal geschrieben werden. Der Tagged-JSON-Encoder verweigert Funktionen, Symbols, Promise, Weak-Collections und Zyklen, statt sie still zu beschädigen — also bewahrt eine persistent-Queue die Herkunft auf, statt das Letter zu verlieren. Ein solcher Eintrag kann nicht erneut zugestellt werden: es ist nichts mehr da, was man senden könnte.
const result = await system.deadLetterQueue.replay(entry.id);
if (result.kind !== 'replayed') {
console.warn('not replayed:', result.kind);
}

Das erneute Zustellen löst den Empfängerpfad neu auf, statt eine zum Fehlerzeitpunkt festgehaltene Ref wiederzuverwenden — der ganze Sinn ist ja, dass der Actor inzwischen zurück ist, unter derselben Adresse als neue Instanz.

Jeder Ausgang hat einen Namen, damit nichts still scheitert:

kindBedeutung
replayedZurückgegeben; der Eintrag hat die Queue verlassen. recipientPath ist, wohin es tatsächlich ging.
unknown-entryKeine solche id — bereits zugestellt oder gealtert.
unresolved-recipientUnter dem Zielpfad ist nichts; der Eintrag bleibt.
degraded-payloadNur Herkunft; es gibt nichts erneut zuzustellen.
quarantinedBereits max-replays-mal erneut zugestellt.

Ein zweites Argument schickt das Letter an eine andere Adresse als die, an die es ursprünglich gesendet wurde — der Actor wurde umbenannt, der Shard ist umgezogen, der Pfad war ein Tippfehler:

const result = await system.deadLetterQueue.replay(
entry.id,
'actor-ts://orders/user/checkout-v2',
);

Drei Dinge sind an einer Umleitung wissenswert:

  • Nur das Ziel ändert sich. Der Sender bleibt der aufgezeichnete, sodass sender beim Empfänger weiterhin den Actor nennt, der die Nachricht gesendet hat, und eine Antwort dorthin geht, wo eine Antwort immer hingeht.
  • Der aufgezeichnete Pfad wird überhaupt nicht aufgelöst. Eine Umleitung ist genau dann am nützlichsten, wenn die ursprüngliche Adresse endgültig weg ist — zu verlangen, dass sie noch existiert, würde also gerade die Fälle verweigern, die sie am meisten brauchen.
  • Die Replay-Schranke gilt weiter. max-replays begrenzt, wie oft dieses Letter erneut zugestellt wird, nicht wie oft ein Empfänger gefragt wird — ein Letter in Quarantäne bleibt verweigert, egal wie es adressiert wird. Sonst gäbe abwechselndes Zustellen an zwei Pfade die unbegrenzte Wiederholungsschleife zurück, die die Schranke schließen soll.

Landet das Letter beim alternativen Empfänger wieder im Dead Letter, kommt es als derselbe Eintrag zurück, der nun den alternativen Pfad als recipientPath führt — dort ist die Nachricht dieses Mal gescheitert.

Giftige Nachrichten können die Queue nicht wachsen lassen

Abschnitt betitelt „Giftige Nachrichten können die Queue nicht wachsen lassen“

Eine erneut zugestellte Nachricht, die wieder im Dead Letter landet, kommt als derselbe Eintrag mit höherem replayCount zurück — nicht als neuer. Ohne das würde ein Betreiber (oder ein Skript), der eine Nachricht erneut versucht, die der Empfänger schlicht nicht verarbeiten kann, pro Versuch einen Eintrag hinzufügen, während jeder einzelne Versuch weiterhin wie ein erster aussähe. Jenseits von max-replays wird das Letter in Quarantäne gestellt und replay verweigert es.

Mit store = "persistent" schreibt die Queue ein Append-only-Log ins konfigurierte Journal und liest es beim nächsten Start zurück, sodass ein Redeploy nicht verliert, was der vorherige Prozess erfasst hat.

Ausgeliefert wird Dauerhaftigkeit über ein geordnetes Herunterfahren, keine Crash-Dauerhaftigkeit — und der Unterschied ist es wert, genau benannt zu werden:

  • Der Stream wird aus dem Systemnamen abgeleitet, sodass zwei Systeme, die sich ein Journal teilen, getrennte Queues behalten. Überschreibe ihn mit actor-ts.dead-letters.persistence-id, wenn du etwas anderes willst.
  • Schreibvorgänge werden beim Eintreffen ausgelöst und beim Herunterfahren abgeschlossen — einmal in der Phase before-actor-system-terminate von CoordinatedShutdown und noch einmal, nachdem der Actor-Baum unten ist, weil das Herunterfahren selbst noch Dead Letters erzeugt, nachdem die letzte Phase gelaufen ist. Alles, was vor einem terminate() erfasst wurde, steht danach im Journal — egal wie groß der Schub war.
  • Ein harter Kill verliert den Rückstau, was nicht dasselbe ist wie einen einzelnen Schreibvorgang zu verlieren. tell ist synchron und kann nicht auf ein Journal warten, Appends werden also „abschicken und vergessen” in eine serialisierte Kette gegeben. Ein Schub, der schneller eintrifft als das Journal ihn annimmt, lässt daher viele unabgeschlossene Appends offen, und ein SIGKILL, ein Stromausfall oder ein OOM-Kill verliert sie alle — nicht nur den gerade laufenden.

Die Queue ist eine Diagnoseaufzeichnung, kein transaktionaler Outbox. Wenn ein Letter einen unkontrollierten Stopp überleben muss, gehört der Journal-Schreibvorgang auf den Sendepfad — ein anderer Entwurf, und nicht dieser hier.

actor_dead_letters_total{outcome} zählt, was die Queue gesehen hat — outcome ist captured, replayed oder replay-failed. Siehe Stock-Metriken.

Es trägt kein Recipient-Label. Das wäre eine dauerhafte Zeitreihe pro unzustellbarem Pfad, und unter Sharding ist der Pfad entity-<entityId> — ein Wert, den derjenige wählt, der die Shard-Region adressiert, zum Preis einer verlorenen Nachricht pro Serie. An welchen Actor ein Letter adressiert war, wird dort beantwortet, wo es nichts pro Serie kostet: list({ recipient }) oben und das DeadLetter auf dem Event-Stream.

Eine Nachricht, die eine Bounded- oder Priority-Mailbox verwirft, wird genau dann zu einem Dead Letter, wenn diese Mailbox mit deadLetterDrops gebaut wurde — und nur dann:

import { ActorOptions, BoundedMailbox, BoundedMailboxOptions } from 'actor-ts';
const sheddingMailbox = BoundedMailboxOptions.create()
.withCapacity(1_000)
.withOverflow('drop-head')
.withDeadLetterDrops(true);
const workerOptions = ActorOptions.create()
.withMailbox(() => new BoundedMailbox(sheddingMailbox));

Bleibt der Schalter aus — das ist die Voreinstellung —, taucht der Überlauf in actor_mailbox_dropped_total auf und hier nirgends.

Der Schalter existiert, weil ein Drop auf dem Stack des Senders passiert und ein Dead Letter eine dauerhafte Erfassung mit anschließender synchroner Veröffentlichung ist: Jeden abgeworfenen Envelope dorthin zu leiten macht aus Load Shedding Arbeit pro Nachricht — und zwar genau unter dem Druck, den die Schranke abfedern sollte. Schalte ihn für die Actors ein, deren Verluste du hinterher erklären müsstest — ein Command-Stream, eine Zustellbestätigung — und lass ihn für den Telemetrie-Feuerwehrschlauch aus, für den die Schranke gedacht ist. Siehe Mailbox-Sizing.

Der Letter trägt die Nachricht, ihren Sender und den Actor, den sie nie erreicht hat — dieselben drei Felder, die jeder andere Verlustpfad festhält. Der MDC-Kontext des Envelopes und sein Tracing-Span überleben ihn nicht; DeadLetter hat für beides keinen Platz.