Skip to the solution
HardSystem Design14 min read

Exactly-once processing — can Kafka or SQS guarantee it?

361 views

The problem

Question presented to candidate: "Our payments consumer reads events from a queue and updates balances. The product team wants each event applied exactly once. Can Kafka or SQS guarantee that for us, and if not, how do you build it?"

What a strong answer should cover:

  • The three delivery semantics (at-most-once, at-least-once, exactly-once) and why the order of "do the side effect" and "commit the offset or delete the message" decides which one you get when a consumer crashes
  • Why exactly-once delivery over a network is not achievable in general, while exactly-once processing (the effect is applied once) is, through idempotency or by committing the effect and the progress atomically
  • What Kafka actually provides: the idempotent producer (producer id plus sequence numbers, on by default since Kafka 3.0) and transactions with read_committed consumers, which give exactly-once for read-process-write inside Kafka, not for external side effects such as a database write or an email
  • What SQS actually provides: FIFO queues drop duplicate sends with the same deduplication id within a 5-minute interval; receiving and processing are still at-least-once, and standard queues can deliver duplicates
  • The practical patterns: idempotent consumers with a dedup key, storing offsets in the same database transaction as the result, the outbox pattern for the producing side, and idempotency keys for external calls

Clarifying questions expected:

  • Where does the side effect live: another Kafka topic, our own database, or an external system such as a payment provider or email service?
  • Does each event carry a stable unique id we can deduplicate on?

Code / implementation expected: Optional — a crash simulation comparing commit-first, effect-first, an idempotent sink and a transactional offset store; the example does exactly that.

  • kafka
  • exactly-once
  • idempotency

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

14 min read · 11 sections

Read the code

Delivery semantics under a crash: at-most-once, at-least-once, idempotent sink, transactional offsets, producer dedup and the FIFO window
// Delivery semantics under a crash, simulated in-process (no real broker).
// The topic is an array; "offset" is the consumer's committed position; the
// sink is a wallet balance. The consumer crashes once, at offset 6.
const events = [];
for (let i = 0; i < 10; i++) events.push({ id: "pay-" + (101 + i), amount: 10 * (i + 1) });
const expected = events.reduce((s, e) => s + e.amount, 0);
const CRASH_AT = 6;

function run(mode) {
  const db = { balance: 0, applied: new Set(), offset: 0 }; // durable state
  let committed = 0, crashed = false, applies = 0;
  while (committed < events.length) {
    const pos = mode === "transactional" ? db.offset : committed; // where we resume
    const e = events[pos];
    const crashNow = !crashed && pos === CRASH_AT;
    if (mode === "at-most-once") {
      committed = pos + 1;                               // 1. commit offset
      if (crashNow) { crashed = true; continue; }        // crash: effect never happens
      db.balance += e.amount; applies++;                 // 2. side effect
    } else if (mode === "at-least-once") {
      db.balance += e.amount; applies++;                 // 1. side effect
      if (crashNow) { crashed = true; continue; }        // crash before commit
      committed = pos + 1;                               // 2. commit offset
    } else if (mode === "idempotent sink") {
      if (!db.applied.has(e.id)) { db.applied.add(e.id); db.balance += e.amount; applies++; } // keyed upsert
      if (crashNow) { crashed = true; continue; }
      committed = pos + 1;
    } else { // transactional: effect and offset in ONE database transaction
      const tx = { balance: db.balance + e.amount, offset: pos + 1 };
      if (crashNow) { crashed = true; continue; }        // crash before COMMIT: tx discarded
      db.balance = tx.balance; db.offset = tx.offset; applies++; committed = db.offset;
    }
  }
  const diff = db.balance - expected;
  console.log(mode.padEnd(16) + " balance=" + String(db.balance).padStart(3) + " expected=" + expected +
    " applies=" + String(applies).padStart(2) + "  " + (diff === 0 ? "correct" : diff > 0 ? "DUPLICATE +" + diff : "LOST " + -diff));
}
console.log("== 1. consumer crash between side effect and offset commit (at offset " + CRASH_AT + ") ==");
for (const m of ["at-most-once", "at-least-once", "idempotent sink", "transactional"]) run(m);

console.log("");
console.log("== 2. producer retry after a lost ack ==");
function produce(idempotent) {
  const log = [], seen = new Map(); // broker-side: producerId -> last sequence number
  const send = (pid, seq, v) => {
    if (idempotent && seen.has(pid) && seen.get(pid) >= seq) return "duplicate ignored";
    seen.set(pid, seq); log.push(v); return "appended";
  };
  const r = [send("P1", 0, "a"), send("P1", 1, "b"), send("P1", 1, "b"), send("P1", 2, "c")]; // ack for seq 1 lost, retried
  console.log((idempotent ? "idempotent producer " : "plain producer      ") + "log=[" + log.join(",") + "] retry -> " + r[2]);
}
produce(false); produce(true);

console.log("");
console.log("== 3. read-process-write transaction that aborts ==");
const out = [{ v: "r1", tx: "T1" }, { v: "r2", tx: "T1" }, { v: "r3", tx: "T2" }, { v: "r4", tx: "T2" }];
const status = { T1: "committed", T2: "aborted" };
const uncommitted = out.map((r) => r.v);
const committedOnly = out.filter((r) => status[r.tx] === "committed").map((r) => r.v);
console.log("read_uncommitted consumer sees [" + uncommitted.join(",") + "]");
console.log("read_committed   consumer sees [" + committedOnly.join(",") + "]");

console.log("");
console.log("== 4. SQS FIFO deduplication window (5 minutes) ==");
const WINDOW = 300, firstSeen = new Map(), fifo = [];
function fifoSend(dedupId, t) {
  if (firstSeen.has(dedupId) && t - firstSeen.get(dedupId) < WINDOW) return "accepted, not enqueued again";
  firstSeen.set(dedupId, t); fifo.push(dedupId); return "enqueued";
}
for (const [id, t] of [["order-42", 0], ["order-42", 60], ["order-42", 299], ["order-42", 360]])
  console.log("t=" + String(t).padStart(3) + "s send " + id + " -> " + fifoSend(id, t));
console.log("queue now holds " + fifo.length + " copies of order-42");

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 87 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design a distributed key-value store?

Continue