Skip to content
About
Main content

Delivery semantics: the basics

Where do at-most-once and at-least-once come from?

is the deep content of the transport choice, and it is where the correctness of this whole mechanism lives. This page gives the basics: where loss and duplicates come from at a crossing. Making a repeat harmless is Fault tolerance's subject.

The three levels, and where they come from

They are not features you configure. They are consequences of the order of two operations: acknowledge and process.

You doYou getOn a crash in between
, then processthe message is lost
Process, then ackthe message is duplicated
Process and ack atomically (within one system)neither

That is all. Every duplicate bug and every silent-loss bug you will ever debug is a question about where the ack sits relative to the side effect. When someone says "we get duplicates after a restart", the answer is already in this table. Here it is as code:

# At-least-once: process first, ack after.
for msg in consumer.poll():
    send_confirmation_email(msg)   # side effect
    # <-- crash here: email was sent, ack was not, so the message comes back
    consumer.ack(msg)

This is Ravi's third problem: the crashed between sending the email and acknowledging. After the restart the delivered the message again, and the customer received a second email.

Exactly-once: what is real and what is not

End-to-end exactly-once across arbitrary systems is impossible. The reason is the lost-ack ambiguity: the confirmation is itself a message that can be lost, so the sender can never learn whether the receiver got the message (why exactly-once delivery is a category error).

So duplicates on the wire are unavoidable. The only real question is where you deduplicate: in an sink (the default), in a transaction inside one system, or in a sink that takes part in . Where the memory of "already done" lives explains each, and where its guarantee ends. The honest name for what most systems achieve is : at-least-once delivery plus an idempotent or deduplicating consumer. For Ravi's confirmation emails, the idempotent sink looks like this:

-- Each confirmation is recorded once per order. A second attempt changes nothing.
INSERT INTO emails_sent (order_id, kind, sent_at)
VALUES (4711, 'confirmation', now())
ON CONFLICT (order_id, kind) DO NOTHING;
-- Send the email only if this INSERT affected 1 row.

(This makes the record idempotent. Sending the email and writing the row are still two steps, so a crash between them can still lose or repeat one email. That is the problem of effects outside the boundary. For email, the usual choice is to accept a rare duplicate.)

How to choose the key and the window, and what Kafka's does and does not deduplicate, are in Idempotency: the three sources and Epochs and sequence numbers.

Order and acknowledgement

: a , a key, a session, a connection. Retries, parallel consumers and re-injection all reorder a partition; the sources, and whether you need order at all, are in Message keys and key-based ordering.

What the 's promises (acks=0, 1 or all, with min.insync.replicas), and why most brokers acknowledge from the , is in Durability across a pipeline.