Skip to main content

Delivery Semantics: What Each Guarantee Costs

Level 9: Streaming & Change Data Capturehard28 minat-most-once and at-least-onceexactly-once semanticsoffset-commit orderingcrash recoveryproducer idempotenceJOINrange predicates

At-most-once and at-least-once are not settings, they are the order in which you commit and process. Derive both from crash-time metadata and name exactly which messages get lost and which get redelivered.

The guarantee is the commit order

Delivery semantics sound like a configuration flag. They are not. They are decided by two lines of your consumer loop and the order you put them in.

Consider a consumer that reads a batch, does the work, and commits its offset. There are exactly two orderings.

Commit first, then process. The moment you commit, the broker believes you are done with those messages. If the process dies between the commit and the work, nobody will ever hand you those messages again. They are gone. This is at-most-once: every message is delivered zero or one times, never twice, and the failure mode is silent data loss.

Process first, then commit. The work is finished before the broker is told. If the process dies between the work and the commit, the restart resumes at the old committed offset and hands you those messages again. This is at-least-once: every message is delivered one or more times, and the failure mode is duplicates. It is Kafka's default posture and it is what almost every real pipeline runs.

Table
Same crash, two orderings, opposite damage. At-most-once loses silently; at-least-once duplicates loudly. Only one of those is recoverable.
orderingtimelinecrash lands heredamagename
commit, then processread -> COMMIT -> process -> ackafter the commit, before the workmessages the broker thinks are done but nothing happened toat-most-once
process, then commitread -> process -> COMMIT -> ackafter the work, before the commitmessages processed once, then handed over again on restartat-least-once
Same crash, two orderings, opposite damage. At-most-once loses silently; at-least-once duplicates loudly. Only one of those is recoverable.

Reading the damage out of the metadata

After a crash you have two artifacts: the consumer's own work log (which offsets it actually finished) and its commit log (what it told the broker). Line them up and the damage falls out of a comparison.

For an at-least-once consumer, look for offsets at or above the committed offset that were already processed successfully. Those are the redeliveries: the work happened, the broker was never told, so the restart will do the work again. Remember that the committed offset is the next one to read, so message_offset >= committed_offset is the correct boundary, not >.

For an at-most-once consumer, look for offsets below the committed offset that were never processed successfully. Those are the losses: the broker was told they were consumed and they never were. No restart brings them back, because from the cluster's point of view there is nothing to redeliver.

Exactly-once, honestly

Kafka does offer exactly-once semantics through transactions plus isolation.level=read_committed, and Flink and Kafka Streams build on it. That is senior awareness. What a junior candidate must be able to say, in one sentence, is the line the whole rest of this level is built on:

True exactly-once delivery is impossible. At-least-once delivery plus an idempotent sink is effectively exactly-once.

That is why Level 8 spent a whole module on making a load idempotent, and it is why the next module applies a changelog with an upsert rather than a blind insert. You do not prevent the duplicate. You make the duplicate harmless.

Two producer-side settings round out the answer. enable.idempotence (on by default since Kafka 3.0) makes the producer's own retries safe, so a network hiccup does not turn one send into two records. And acks trades latency against durability: acks=0 never waits, acks=1 waits for the leader only and loses data if that leader dies before replicating, acks=all waits for the in-sync replicas and is what you use for anything you would be embarrassed to lose.

Common mistake: treating at-least-once as "the safe one" and stopping there. At-least-once is only safe if the thing downstream can absorb a repeat. Sending a payment email twice, incrementing a counter twice, or appending to a ledger twice are all real incidents. The guarantee is half the answer and the sink is the other half.

Interview nuance: when you are asked "which delivery semantic would you choose", the answer that scores is not a semantic, it is a pair. Say at-least-once plus an idempotent sink keyed on something stable, and then name the key you would use. An interviewer is checking whether you know that duplicates are a design input rather than a bug to be argued away.

On a real platform this differs. Here you reconstruct the crash from two tables after the fact. On a real cluster you would not reconstruct anything, because the consumer's own code decides the ordering and you read the outcome from your sink. The tables here stand in for what a real consumer emits: a processing log or DLQ table on one side, and the broker's __consumer_offsets topic on the other. On AWS the same reasoning applies unchanged to a Kinesis consumer's checkpoint in its DynamoDB lease table, which is the direct analogue of a committed offset.

Sample data for this example
CREATE TABLE processing_attempts (
  group_id        TEXT,
  partition_id    INTEGER,
  message_offset  INTEGER,
  processed_ok    INTEGER,  -- 1 = the handler finished; 0 = it was handed to the consumer and never completed
  processed_at_ms INTEGER   -- epoch ms the handler finished; NULL when it never did
);
INSERT INTO processing_attempts (group_id, partition_id, message_offset, processed_ok, processed_at_ms) VALUES
  ('order-enricher', 0, 5008, 1, 1767290395000),
  ('order-enricher', 0, 5009, 1, 1767290396000),
  ('order-enricher', 0, 5010, 1, 1767290397000),
  ('order-enricher', 0, 5011, 1, 1767290398000),
  ('order-enricher', 0, 5012, 1, 1767290399000),
  ('order-enricher', 0, 5013, 0, NULL),
  ('order-enricher', 1, 8002, 1, 1767290393000),
  ('order-enricher', 1, 8003, 1, 1767290394000),
  ('order-enricher', 1, 8004, 1, 1767290397000),
  ('order-enricher', 1, 8005, 1, 1767290399000),
  ('order-enricher', 1, 8006, 0, NULL),
  ('email-blaster',  0, 9100, 1, 1767290391000),
  ('email-blaster',  0, 9101, 1, 1767290393000),
  ('email-blaster',  0, 9102, 0, NULL),
  ('email-blaster',  0, 9103, 0, NULL),
  ('email-blaster',  0, 9104, 0, NULL),
  ('email-blaster',  1, 4399, 1, 1767290390000),
  ('email-blaster',  1, 4400, 1, 1767290392000),
  ('email-blaster',  1, 4401, 0, NULL);
CREATE TABLE consumer_commits (
  group_id         TEXT,
  partition_id     INTEGER,
  committed_offset INTEGER,  -- the NEXT offset the group will read after restart
  commit_ts_ms     INTEGER
);
INSERT INTO consumer_commits (group_id, partition_id, committed_offset, commit_ts_ms) VALUES
  ('order-enricher', 0, 5010, 1767290340000),
  ('order-enricher', 1, 8004, 1767290342000),
  ('email-blaster',  0, 9105, 1767290398000),
  ('email-blaster',  1, 4402, 1767290399000);
Worked example (SQL)
-- Replay one partition of the crash: what each offset's fate is once the consumer restarts.
SELECT a.message_offset,
       a.processed_ok,
       c.committed_offset,
       CASE
         WHEN a.processed_ok = 1 AND a.message_offset >= c.committed_offset THEN 'will be redelivered'
         WHEN a.processed_ok = 1 THEN 'done and committed'
         ELSE 'never finished'
       END AS fate_after_restart
FROM processing_attempts a
JOIN consumer_commits c ON c.group_id = a.group_id AND c.partition_id = a.partition_id
WHERE a.group_id = 'order-enricher' AND a.partition_id = 0
ORDER BY a.message_offset;

Apply

Your turn

The task this lesson builds to.

Write a query that returns, for the order-enricher group, the messages that will be delivered a second time after the crash, as (partition_id, message_offset), ordered by partition_id then message_offset, over processing_attempts(group_id, partition_id, message_offset, processed_ok, processed_at_ms) joined to consumer_commits(group_id, partition_id, committed_offset, commit_ts_ms).

This group commits after processing. A message will be handed over again exactly when the work already finished but the offset was never committed past it. The committed offset is the NEXT offset the group will read, so choose the boundary comparison carefully.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a query that returns every message any consumer group lost forever, as (group_id, partition_id, message_offset, committed_offset), ordered by group_id, then partition_id, then message_offset, over the same two tables.

Both groups are in the tables and only one of them commits before processing, so do not filter by group name. Decide from the data instead: an unfinished message is only LOST when the broker already believes the group consumed it. An unfinished message the broker will hand over again is a first delivery, not a loss, and must not appear.

1 hint and 1 automated check are waiting in the workspace.