ACK-Semantik
Das ACK ist das Kern-Primitiv zuverlässiger Zustellung — der Producer gibt gebufferte Nachrichten frei, sobald sie geACKt sind. Der Controller verwaltet das ACK-Timing für Dich; wie der Handler resolved, steuert, ob das ACK feuert.
Wie das ACK wirklich feuert
Abschnitt betitelt „Wie das ACK wirklich feuert“1. ConsumerController empfängt ein Delivery-Envelope2. Controller prüft den Dedup-State pro Producer → bekannte Seq → nur re-ACK, Handler überspringen → neue Seq → handler(body) aufrufen, auf Resolve warten3. Handler resolved → Controller sendet ACK an msg.replyTo Handler wirft → Controller ACKt NICHT4. Producer empfängt ACK → entfernt Seq aus dem In-Flight-Buffer → gibt einen Window-Slot für den nächsten Send freiDer Handler ist der einzige User-seitige Schalter. Was im
handler(body) passiert, ist Dein Code; was darum herum
passiert (Dedup, ACK, replyTo-Verdrahtung, Retransmit-Timing)
ist Aufgabe des Controllers.
Drei Muster
Abschnitt betitelt „Drei Muster“| Muster | Handler-Form | Garantie |
|---|---|---|
| Resolve bei Erfolg (Default) | Handler erledigt die Arbeit, gibt void oder ein resolved Promise zurück | at-least-once mit In-Memory-Dedup |
| Wirft bei Misserfolg | Handler wirft / rejected, wenn die Verarbeitung fehlschlug → Controller überspringt das ACK → Producer sendet erneut | at-least-once mit Retry |
| Vor Resolve persistieren | Handler awaitet einen Journal-Write, bevor er zurückkehrt | effectively-once über Consumer-Restart hinweg |
new ConsumerController<T>({ handler: async (body) => { await this.processIdempotently(body); // ACK-bei-Resolve },});Was zwischen Verarbeitung und ACK passiert
Abschnitt betitelt „Was zwischen Verarbeitung und ACK passiert“1. Handler beginnt die Verarbeitung (body)2. Handler abgeschlossen (resolved)3. Controller serialisiert ein ACK-Envelope4. Controller tellt die replyTo-Ref des Producers5. Producer entfernt msg aus dem Unacked-BufferWenn der Consumer zwischen 2 und 4 crasht, erreicht das
ACK den Producer nie. Der Producer retransmittiert nach
resendTimeoutMs. Der In-Memory-Dedup des Controllers ist nach
dem Crash weg, also läuft der Handler für dieselbe Seq erneut —
mach ihn idempotent oder persistiere die processed-Seq zusammen
mit dem Business-State.
Vor Resolve persistieren
Abschnitt betitelt „Vor Resolve persistieren“Für effectively-once-Verarbeitung von Seiteneffekten await
den Journal-Write innerhalb des Handlers. Wenn
handler(body) resolved, sind sowohl Dein Event ALS AUCH der
implizite “diese Seq ist verarbeitet”-Marker durable; das
folgende ACK des Controllers teilt dem Producer nur mit, dass
er seinen Slot freigeben kann.
class PersistentEventLog extends PersistentActor<Cmd, Event, State> { // …onCommand / onEvent ausgelassen…}
const log = system.spawnAnonymous(Props.create(() => new PersistentEventLog()));
const consumer = system.spawn( Props.create(() => new ConsumerController<OrderEvent>({ handler: async (order) => { // ask() resolved, sobald der PersistentActor das Event // journalt hat. Crasht der Consumer, bevor das resolved, // retransmittiert der Producer und der Handler läuft // erneut — aber der Dedup des PersistentActor (Event-ID // oder Seq im Event-Payload) fängt den Re-Run ab. await log.ask({ kind: 'append', order }, 30_000); }, })),);Die Sequenz bei sauberem Lauf:
- Delivery kommt an.
- Controller ruft
handler(body)auf. - Handler fragt das Journal, das Journal schreibt, das ask resolved.
- Controller sendet ACK → Producer gibt seinen Slot frei.
Bei Crash zwischen Schritt 2 und Schritt 3’s Resolve:
- Bevor der Journal-Write committed: Producer retransmittiert; der Handler läuft erneut; der Dedup auf Journal-Seite fängt ihn ab. Netto: keine Duplizierung.
- Nachdem der Journal-Write committed hat, aber bevor das ACK des Controllers den Producer erreicht: Producer retransmittiert; der Handler läuft erneut; der Dedup auf Journal-Seite springt sofort raus und der Controller ACKt sofort. Netto: keine Duplizierung.
Für End-to-End-Durability paare das mit persistentem Producer-State — speichere den In-Flight-Buffer des Producers in einem Journal, damit ein Producer-seitiger Crash keine unbestätigten Nachrichten verliert.
Crash-Szenarien
Abschnitt betitelt „Crash-Szenarien“Producer-Crash vor dem Senden
Abschnitt betitelt „Producer-Crash vor dem Senden“Producer sendet msg #5 → Crash, bevor das Transmit abgeschlossen istRecovery: Producer startet neu. Ohne Persistenz ist msg #5 verloren. Mit Persistenz ist die Nachricht im Unacked-Buffer-Journal des Producers; wird beim Recovery gesendet.
Consumer-Crash vor der Verarbeitung
Abschnitt betitelt „Consumer-Crash vor der Verarbeitung“Consumer empfängt msg → crasht, bevor der Handler läuftKein ACK gesendet; Producer retransmittiert. Idempotenter Handler dedupliziert (oder verarbeitet zum ersten Mal).
Netzwerk-Drop zwischen Übertragung und ACK
Abschnitt betitelt „Netzwerk-Drop zwischen Übertragung und ACK“Producer sendet msg #5Consumer verarbeitet msg #5Consumer sendet ACK #5Netzwerk verliert das ACK-EnvelopeProducer retransmittiert msg #5 nach Timeout. Consumer dedupliziert (highWatermark schon bei 5); ackt erneut.
Das Dedup-und-Re-ACK-Muster heißt, transiente ACKs sind wiederherstellbar — irgendwann kommt eines an.
Langsamer Consumer
Abschnitt betitelt „Langsamer Consumer“Producer.windowSize = 16Consumer braucht 10 s pro NachrichtBackpressure: sobald 16 unbestätigte Nachrichten in Flight
sind, sendet der Producer nicht weiter, bis ein ACK einen
Slot freigibt. Eingehende
{ kind: 'reliable-delivery.send' }-Tells reihen sich innerhalb
des Producers ein, bis das Window aufgeht.
Das ist gut — das System kann nicht weiter vor sich selbst davonlaufen als das Flow-Control-Window.
Cluster-Failover
Abschnitt betitelt „Cluster-Failover“Consumer auf node-A verarbeitet msg #5; Controller queued das ACKnode-A crasht, bevor das ACK-Tell abgeschlossen istProducer sieht im resendTimeoutMs-Fenster kein ACK →
retransmittiert.
Wenn der Consumer sharded ist (die Entity wandert beim Failover), wird die neue Instanz:
- Den Retransmit empfangen.
- Einen leeren In-Memory-Dedup-State haben (frischer
ConsumerController). - Den Handler aufrufen — der für Effectively-once seine eigene processed-Seq persistieren muss.
Für sharded Consumer immer den Handler mit Persistenz paaren — sonst setzt der Dedup des Controllers bei jedem Rebalance zurück, und Du bekommst volle Wiederverarbeitung.
ACK als Garantie (oder nicht)
Abschnitt betitelt „ACK als Garantie (oder nicht)“Das ACK ist Fire-and-Forget vom Controller zum replyTo des
Producers. Der Controller wartet nicht auf die Zustellung —
wenn das Netzwerk das ACK verliert, retransmittiert der Producer
nach resendTimeoutMs, und der Dedup des Controllers re-ACKt
sofort.
Das heißt: Dein Handler zahlt keine Latenz-Kosten für das
ACK. Der Controller emittiert das ACK, nachdem
handler(body) resolved, und macht weiter.
Wann du die Controller NICHT einsetzt
Abschnitt betitelt „Wann du die Controller NICHT einsetzt“Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- Delivery im Überblick — das Gesamtbild.
- ProducerController — die Sender-Seite.
- ConsumerController — die Empfänger-Seite.
- PersistentActor — für End-to-End durable Verarbeitung.