A consumer folds 100 "+1" events into an external total but commits its offset only every 10 events. Crash it having applied through offset 49 with the offset committed only at 39, and restart replays offsets 40–49: the naive counter lands at 110, not 100. Stamp the triggering offset and reject anything not strictly newer, and the same replay is a no-op — exactly 100.
A consumer reads an append-only log of 100 "+1" events and folds each one into an external total. It commits its consumer offset only periodically — a checkpoint every 10 events — so the offset it has durably committed lags the events it has already applied. Crash it in that gap: it has applied through offset 49 but its last committed checkpoint is at offset 39. On restart it resumes from 40 and reprocesses offsets 40–49 — at-least-once delivery, so those ten events arrive a second time. The naive consumer just increments, so the replay lands the counter at 110, not 100 — redelivered messages inflate it. Stamp the triggering offset alongside the value and reject anything not strictly newer, and the same replay is a no-op: the total is exactly 100.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 12 — Stream Processing, §"Fault tolerance" (§"Idempotence", §"Exactly-once execution"):
An idempotent operation is one that you can perform multiple times, and it has the same effect as if you performed it only once. [...] you can safely retry without causing the operation to take effect twice.
The book's framing: a stream processor that must survive crashes can cheaply guarantee at-least-once delivery (replay from the last committed offset), but not exactly-once without more work. Making the side effect idempotent — often by recording which offset produced it — turns at-least-once into effectively-once. Here you watch the double-count happen, then close it with one offset stamp.
The surprise lives in the gap between two separate writes — the side effect and the offset commit. These help; each line notes where it shows up.
total += 1 is not idempotent; a "set the value and remember the offset" write is. It shows up as the IdempotentStore.apply that a replay leaves unchanged.offset <= highest_applied skip that drops offsets 40–49 on replay.At-least-once delivery means every event is applied at least once — never lost, but possibly repeated. A stream consumer earns that guarantee cheaply: it commits its read position (the offset) only now and then, and on restart it rewinds to the last committed offset and replays forward. Everything it had already applied past that offset gets applied again.
total += 1 has no memory; applied twice, it adds twice. Ten replayed events add ten too many.Both stores implement the same apply(offset, delta) interface; the exercise drives each through the identical crash-and-replay schedule. The whole thing is one script, code/at_least_once_double_count.py.
cd study/ddia/ch12
python3 code/at_least_once_double_count.py
Do not read the output yet — make each prediction first.
Predict. The log has 100 events, each "+1". If every event is applied exactly once, what does the external total read?
100 — no surprise yet. The surprise is what a crashed-and-replayed consumer does to it.
Predict. The consumer applied through offset 49 but committed its offset only up to 39, then crashed. It restarts, resumes from offset 40, and reprocesses 40–49 before finishing 50–99 — incrementing on every delivery, with no dedup. What is the final total, and by how much is it off?
110, not 100 — inflated by exactly 10. The ten events between the last committed offset (39) and the crash point (49) were already applied in the first run; the replay applies them a second time. A lost message would undercount; a redelivered one overcounts. Either way the counter is wrong.
Predict. Same log, same crash, same replay of 40–49 — but now the store records the highest offset it has applied and refuses any event whose offset is not strictly newer. What is the final total, and which offsets does it skip?
Exactly 100 — the ten replayed offsets are skipped. The first run left the store remembering highest_applied = 49. On replay, offsets 40–49 are all ≤ 49, so apply returns without touching the total; the first strictly-newer offset it acts on is 50. The redelivery still happens — at-least-once is unchanged — but its effect is now a no-op.
Predict. Side by side, what do the naive and idempotent consumers report, and what accounts for the difference?
One difference: whether the store remembers which offset it already applied. Same delivery guarantee, same replay — the naive store has no memory and double-counts, the idempotent store stamps the offset and dedups.
At-least-once delivery is not a bug — it is the honest guarantee a crash-tolerant consumer can afford. Committing the read offset after every event would be correct but expensive, so real consumers commit periodically and accept that a crash rewinds them to the last committed offset. The cost of that cheap guarantee is a replay window: every event applied after the last commit but before the crash will be delivered again.
The double-count comes from a subtler place: the side effect and the offset commit are two separate writes, and a crash can split them. The consumer applies event 49 to the external total, then — as a distinct operation — commits its offset. Between those two writes the state is inconsistent: the effect has happened but the log still believes it hasn't. Crash there and recovery faithfully replays from offset 40, re-applying effects that already took hold. With a non-idempotent operation (total += 1, which has no notion of "already done"), each replayed delivery adds again — ten replays, ten spurious increments, total 110. This is why the book warns that partial failure between the effect and the offset commit corrupts derived state.
Recording which offset produced a value is what closes the gap. The idempotent store keeps highest_applied next to total and turns the increment into a set-if-newer: apply only when offset > highest_applied, and stamp the new offset atomically with the value. Now a redelivered event carries its own offset (40..49), the store sees each is ≤ the 49 it already recorded, and rejects it — the second application has the same effect as none, which is precisely the definition of idempotence. At-least-once delivery is unchanged; what changed is that a repeat is now harmless, so the guarantee is effectively-once.
max a gauge), a second delivery is already a no-op and needs no offset stamp at all. The inflation you saw requires the unlucky combination: an effect that accumulates (so repeats matter) committed separately from the offset (so a crash can replay it). Stamping the offset is the cheap fix when you can't get atomicity across the effect and the log.
total and the committed offset in the same object and commit them together; confirm the naive increment now survives the crash without a dedup set, because recovery never replays a committed effect.Sources: DDIA 2e, Ch. 12, §"Fault tolerance", §"Idempotence", §"Exactly-once execution".