コンテンツにスキップ
日本語

Ack semantics

このコンテンツはまだ日本語訳がありません。

The ack is the core primitive of reliable delivery — the producer releases buffered messages once acked. The controller manages ack timing for you; how the handler resolves controls whether the ack fires.

1. ConsumerController receives a Delivery envelope
2. Controller checks per-producer dedup state
→ known seq → re-ack only, skip handler
→ new seq → call handler(body), wait for resolution
3. Handler resolves → controller sends Ack to msg.replyTo
Handler throws → controller does NOT ack
4. Producer receives Ack → removes seq from its in-flight buffer
→ frees a window slot for the next send

The handler is the only user-facing knob. Whatever happens inside handler(body) is your code; whatever happens around it (dedup, ack, replyTo wiring, retransmit timing) is the controller’s job.

PatternHandler shapeGuarantee
Resolve on success (default)Handler does the work, returns void or resolved PromiseAt-least-once with in-memory dedup
Throw on failureHandler throws / rejects when processing failed → controller skips ack → producer resendsAt-least-once with retry
Persist before resolveHandler awaits a journal write before returningEffectively-once across consumer restart
new ConsumerController<T>({
handler: async (body) => {
await this.processIdempotently(body); // ack-on-resolve
},
});
1. Handler starts processing (body)
2. Handler completes (resolves)
3. Controller serialises an Ack envelope
4. Controller tells the producer's replyTo ref
5. Producer removes msg from unacked buffer

If the consumer crashes between 2 and 4, the ack never reaches the producer. Producer retransmits after resendTimeoutMs. The controller’s in-memory dedup is gone after the crash, so the same seq runs the handler again — make it idempotent, or persist processed-seq alongside business state.

For effectively-once processing of side effects, await the journal write inside the handler. When handler(body) resolves, both your event AND the implicit “processed this seq” marker are durable; the controller’s subsequent Ack just tells the producer it can free its slot.

class PersistentEventLog extends PersistentActor<Cmd, Event, State> {
// …onCommand / onEvent omitted…
}
const log = system.spawnAnonymous(Props.create(() => new PersistentEventLog()));
const consumer = system.spawn(
Props.create(() => new ConsumerController<OrderEvent>({
handler: async (order) => {
// ask() resolves once the PersistentActor has journalled
// the event. If the consumer crashes before this resolves,
// the producer will retransmit and the handler runs again —
// but the PersistentActor's own dedup (event-id or seq in
// event payload) handles the rerun.
await log.ask({ kind: 'append', order }, 30_000);
},
})),
);

The sequence on a clean run:

  1. Delivery arrives.
  2. Controller calls handler(body).
  3. Handler asks the journal, the journal writes, the ask resolves.
  4. Controller sends Ack → producer frees its slot.

On crash between step 2’s start and step 3’s resolve:

  • Before the journal write commits: producer retransmits; the handler runs again; the journal-side dedup catches it. Net: zero duplication.
  • After the journal write commits, before the controller’s Ack reaches the producer: producer retransmits; the handler runs again; the journal-side dedup short-circuits and the controller re-acks immediately. Net: zero duplication.

For end-to-end durability, pair this with persistent producer state — store the producer’s in-flight buffer in a journal so a producer-side crash doesn’t lose unacked messages.

Producer sends msg #5 → crash before transmit completes

Recovery: producer restarts. Without persistence, msg #5 is lost. With persistence, the message is in the producer’s unacked-buffer journal; transmits on recovery.

Consumer receives msg → crashes before handler runs

No ack sent; producer retransmits. Idempotent handler dedupes (or processes for the first time).

Producer sends msg #5
Consumer processes msg #5
Consumer sends ack #5
Network drops the ack envelope

Producer retransmits msg #5 after timeout. Consumer dedupes (highWatermark already at 5); re-acks.

The dedup + re-ack pattern means transient acks are recoverable — eventually one arrives.

Producer.windowSize = 16
Consumer takes 10s per message

Backpressure: after 16 unacked messages are in flight, the producer stops sending further messages until an Ack frees a slot. Incoming { kind: 'reliable-delivery.send' } tells queue inside the producer until the window opens.

This is good — the system can’t get further ahead of itself than the flow-control window.

Consumer on node-A processes msg #5; controller queues the Ack
Node-A crashes before the Ack tell completes

Producer doesn’t see Ack within resendTimeoutMs → retransmits.

If the consumer is sharded (the entity moves during failover), the new instance:

  • Receives the retransmit.
  • Has empty in-memory dedup state (fresh ConsumerController).
  • Calls the handler — which must persist its own processed-seq for effectively-once.

For sharded consumers, always pair the handler with persistence — otherwise the controller’s dedup resets on every rebalance and you get full reprocessing.

The Ack is fire-and-forget from the controller to the producer’s replyTo. The controller doesn’t await its delivery — if the network drops the Ack, the producer retransmits after resendTimeoutMs and the controller’s dedup re-acks immediately.

This means: your handler doesn’t pay any latency cost for the Ack. The controller emits the Ack after handler(body) resolves and moves on.