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<Command, Event, State> { // …onCommand / onEvent ausgelassen…}
const log = system.spawnAnonymous(PersistentEventLog);
const consumer = system.spawn( () => 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.
Das ACK auf der Producer-Seite beobachten
Abschnitt betitelt „Das ACK auf der Producer-Seite beobachten“Der Consumer-Handler entscheidet, ob das ACK feuert; mit dem
confirm-Callback des Producers beobachtet Dein eigener
Code, dass es eintrifft. Greif zur ergonomischen
ReliableDelivery-Fassade: ReliableDelivery.producer gibt
einen ProducerHandle zurück, dessen tell(body, confirm?)
einen optionalen ConfirmationCallback entgegennimmt —
(err: Error | null) => void.
const consumer = ReliableDelivery.consumer<OrderEvent>(system, { handler: async (order) => { await save(order); },});
const producerOptions = ProducerControllerOptions.create<OrderEvent>().withConsumer(consumer.ref as never);const producer = ReliableDelivery.producer<OrderEvent>(system, producerOptions);
producer.tell(order, (err) => { // err === null → der Consumer hat diese Seq geACKt; der Slot wird frei // err !== null → der Producer wurde vor Eintreffen des ACK gestoppt});confirm(null) feuert genau einmal — nachdem das ACK des
Consumers den Producer erreicht hat und der In-Flight-Slot
freigegeben ist. Ein erneut zugestelltes (doppeltes) ACK für
dieselbe Seq wird ignoriert, sodass der Callback nie doppelt
feuert. Das ist das einzige Producer-seitige Signal, dass
eine Nachricht angekommen ist; ohne ihn ist der Send aus Sicht
Deines Codes Fire-and-Forget.
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.
