Zum Inhalt springen
Deutsch

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.

1. ConsumerController empfängt ein Delivery-Envelope
2. Controller prüft den Dedup-State pro Producer
→ bekannte Seq → nur re-ACK, Handler überspringen
→ neue Seq → handler(body) aufrufen, auf Resolve warten
3. Handler resolved → Controller sendet ACK an msg.replyTo
Handler wirft → Controller ACKt NICHT
4. Producer empfängt ACK → entfernt Seq aus dem In-Flight-Buffer
→ gibt einen Window-Slot für den nächsten Send frei

Der 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.

MusterHandler-FormGarantie
Resolve bei Erfolg (Default)Handler erledigt die Arbeit, gibt void oder ein resolved Promise zurückat-least-once mit In-Memory-Dedup
Wirft bei MisserfolgHandler wirft / rejected, wenn die Verarbeitung fehlschlug → Controller überspringt das ACK → Producer sendet erneutat-least-once mit Retry
Vor Resolve persistierenHandler awaitet einen Journal-Write, bevor er zurückkehrteffectively-once über Consumer-Restart hinweg
new ConsumerController<T>({
handler: async (body) => {
await this.processIdempotently(body); // ACK-bei-Resolve
},
});
1. Handler beginnt die Verarbeitung (body)
2. Handler abgeschlossen (resolved)
3. Controller serialisiert ein ACK-Envelope
4. Controller tellt die replyTo-Ref des Producers
5. Producer entfernt msg aus dem Unacked-Buffer

Wenn 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.

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:

  1. Delivery kommt an.
  2. Controller ruft handler(body) auf.
  3. Handler fragt das Journal, das Journal schreibt, das ask resolved.
  4. 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.

Producer sendet msg #5 → Crash, bevor das Transmit abgeschlossen ist

Recovery: 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 empfängt msg → crasht, bevor der Handler läuft

Kein ACK gesendet; Producer retransmittiert. Idempotenter Handler dedupliziert (oder verarbeitet zum ersten Mal).

Producer sendet msg #5
Consumer verarbeitet msg #5
Consumer sendet ACK #5
Netzwerk verliert das ACK-Envelope

Producer 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.

Producer.windowSize = 16
Consumer braucht 10 s pro Nachricht

Backpressure: 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.

Consumer auf node-A verarbeitet msg #5; Controller queued das ACK
node-A crasht, bevor das ACK-Tell abgeschlossen ist

Producer 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.

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.