Delivery Semantics: Advanced
What you will be able to do
The gap between effect and progress
| Order in the consumer | Crash between the steps | Restart reads from | Outcome |
|---|---|---|---|
| Write, then commit | Rows written, offset not saved | Old offset | Batch replayed as duplicates: at-least-once |
| Commit, then write | Offset saved, rows not written | New offset | Batch skipped as loss: at-most-once |
| Write and commit in 1 atomic step | Both happened or neither did | The offset matching the data | No gap: exactly-once effect |
| Idempotent write, then commit | Rows written, offset not saved | Old offset | Batch replayed, and the keyed write absorbs it |
The consumer makes 2 separate writes per batch: ledger rows to the table and its position to the offset store. Every guarantee is defined by the gap between them.
The guarantee is end to end
| Link | Must provide | Ceiling if it does not |
|---|---|---|
| Source | Replay from a stored position for longer than recovery takes | At-most-once: a crashed batch cannot be read again |
| Processing | Progress recorded after the effect, or atomically with it | At-most-once wherever progress runs ahead of effects |
| Sink | Idempotent writes, or a transaction shared with the progress | At-least-once: every replay surfaces as a duplicate |
The question in disguise
- "Our daily count runs 0.4% high after every deploy."Each deploy restarts consumers, and each restart replays the uncommitted tail.
- "A customer got 2 shipping confirmation emails."A side effect outside any transaction was repeated by a retry.
- "Tuesday's revenue dropped after we added a column."A schema change broke parsing or a key, so records were skipped or rekeyed.
- "How would you backfill last month?"A rerun is a deliberate replay, and the sink must absorb it.
- "What if your job is down for 9 days?"Lag beyond the log's 7-day retention turns replay into loss.
Where duplicates come from
| Source of the repeat | Trigger | Size of 1 incident |
|---|---|---|
| Producer retry | The log stored the write, the acknowledgment timed out, and the producer sent again | One extra copy per timed-out send |
| Consumer replay | Crash or deploy between effect and commit | Records applied since the last commit |
| Rebalance | A partition moves to another consumer before its old owner commits | The uncommitted tail of each moved partition |
| Rerun or backfill | An operator reprocesses a day or a partition | The entire rerun range |
A rebalance also fires when a consumer goes longer than its maximum allowed interval between polls, so a slow write can evict it and cause a replay with no crash at all. The session timeout covers a different failure: missed heartbeats from a process that died or lost the network.
Candidates forget the producer retry. A producer sends charge pay_8812, the log stores it, and the acknowledgment is lost on the network. From the producer's side the write failed, so it sends again, and the log now holds 2 copies at different offsets. Consumer commit discipline cannot remove this repeat, because the 2 copies are distinct records. Only a component that recognizes pay_8812 as 1 event can.
Idempotency needs a key from the origin
| offset | event_key | payment_id | amount |
|---|---|---|---|
| 48,100 | a1f3e9 | pay_8812 | 49.00 |
| 48,101 | 7c2eb0 | pay_8812 | 49.00 |
| offset | event_key | payment_id | amount |
|---|---|---|---|
| 48,100 | pay_8812 | pay_8812 | 49.00 |
| 48,101 | pay_8812 | pay_8812 | 49.00 |
Dedup on event_key leaves 2 charges of 49.00 on the left and 1 on the right. The fix lives at the producer and is a contract change, so name it as one: every producer stamps a stable ID, and events arriving without one go to quarantine instead of being trusted.
Making a table sink absorb replays
Sizing the dedup state
Failures that survive at-least-once
- Increments. A sink running balance = balance + amount applies a replay twice. Store the events and derive the balance, or upsert each (account, window) aggregate with its recomputed value.
- Payload-hash keys. If the key is a hash of the whole record, adding a column changes every hash, and a replay after the schema change slips past dedup as new events. Key on the producer's ID.
- External calls. An email or webhook sent per record repeats on replay. Write the send to a keyed table and check it before sending, or pass the event ID to a receiver that dedups on it.
- Copies older than the window. A copy arriving after the dedup window has expired counts as new. Long outages and backfills are the usual cause.
Total amount is corrupted: each copy adds its amount again. Distinct cards is safe, because a second copy of card c_17 adds nothing to a set that already holds it. Classify each aggregate by whether a repeat changes it before building dedup machinery; an idempotent aggregate needs none.
When losing a record is the right call
- Sampled telemetry, where 1 point in 10,000 missing changes no chart
- Live dashboard tiles overwritten by the next value seconds later
- Marketing pushes, where a second copy annoys more than a missed one costs
- High-volume debug logs that would cost more to retry durably than they are worth
- Anything summed into money: charges, refunds, payouts
- Training labels for a model, where loss becomes invisible bias
- Audit trails a regulator can ask you to reproduce
- Any stream whose consumers assume completeness
Measure the loss you accepted
Accidental at-most-once
| Hidden loss | How it happens | Property that prevents it |
|---|---|---|
| Retention expiry | Consumer lag outgrows the log's 7-day retention, and unread records are deleted | A lag alert well inside retention, and retention longer than your worst planned outage |
| Late data past the watermark | A watermark is the job's estimate of how far event time has progressed; a window closes once the watermark passes its end plus the allowed lateness, and events for it arriving later are discarded | Late events routed to a side output that corrects the window |
| Poison record skipped | Parsing fails, and the job logs the error and commits past it | A dead-letter queue with an owner and a replay path |
| Incompatible schema change | A new producer version emits a type the consumer cannot parse, so every record is skipped | A compatibility check before publish, and failing closed instead of skipping |
| Async write, early commit | The consumer fires a non-blocking write and commits before it is confirmed | Committing only offsets whose writes were acknowledged |
Transactions inside 1 system
Transactions across systems
Latency and coordination costs of transactions
| Cost | Mechanism | Example figure |
|---|---|---|
| Visibility latency | Readers that skip uncommitted data see output only after the checkpoint commits | With 60-second checkpoints, up to 60 seconds stale and 30 on average |
| Invisible reprocessing | Everything since the last checkpoint reruns after a crash | Up to 1.2M records at 20,000 per second |
| Transaction timeout | A checkpoint plus recovery that outlasts the sink's timeout lets the sink abort pre-committed output | A 15-minute timeout against a 20-minute recovery |
| Barrier coordination | Every task must reach the checkpoint before commit, so a slow task delays all output | A single skewed partition out of 200 sets commit time for all of them |
| Blocked readers | Committed-only readers wait behind the oldest open transaction | A stuck transaction stalls consumers until it times out |
Replay into an idempotent sink
- Output visible at checkpoint commit
- No dedup state in the sink
- Breaks when a sink lacks transactions or a timeout fires mid-recovery
- Side effects outside the transaction still repeat
- Output visible on write
- Needs a stable origin key on every event
- Breaks when keys are regenerated or derived from the payload
- Works with any sink that can upsert or reject a known key
The boundary exactly-once cannot cross
The 4 questions also order your interview time: spend it on the sinks where both a duplicate and a loss are expensive, because those need the costliest mechanism.
A stream with 4 consumers
Each consumer reads the same charge events at its own committed position, and its sink sets the guarantee it needs.
| Consumer | Duplicate cost | Loss cost | Guarantee | Mechanism |
|---|---|---|---|---|
| Ledger writer | A double charge on the books | Missing revenue and a failed audit | Exactly-once | Ledger rows and offsets in 1 transaction |
| Fraud features | None, for distinct-count features | A card missed by a velocity check | At-least-once | Replay into aggregates that absorb repeats |
| Receipt sender | A second email to the customer | No receipt, then a support ticket | At-least-once with dedup | Send log keyed by payment_id, plus the provider's idempotency key |
| Live dashboard | A tile briefly high | A tile briefly low | At-most-once | Commit first, reconcile hourly against the ledger |
Structure of a complete answer
- Guarantee per sinkEach consumer's guarantee, with the business cost that justifies it.
- MechanismThe order of commit and effect, and the key or transaction that enforces that order.
- Failures handledHow the design handles a retry and a late record, and what happens when the schema changes.
- The numberWhatever the guarantee costs, sized from throughput: the duplicates or losses per crash, or else the dedup state or visibility latency it needs.
- What it still missesThe edge the guarantee leaves uncovered, and how you detect it: a reconciliation, a lag alert, a dead-letter owner.
Retry, late data and schema change in the ledger
| Failure | What the ledger writer does | Result |
|---|---|---|
| Producer retries pay_8812 after a lost acknowledgment | The second copy hits insert-if-absent on payment_id and is skipped inside the transaction | A single ledger row |
| A charge from a terminal offline for 3 hours | Rows key on payment_id and book by event_time, with no watermark to drop the event | The row lands in the correct day, and that day's total is restated |
| Producer adds a currency_code column with a default | Old and new records both parse, and payment_id is unchanged | No rekeying, no skipped records |
| The amount column changes type to string | Parsing fails, so the writer stops and alerts instead of committing past the record | Lag grows; nothing lost, nothing doubled |
- Mint the event ID where the event is born and carry it to every sink
- Size duplicates per crash from throughput times commit interval
- Fail closed on unparseable records and alert on lag
- Reconcile source and sink counts for any sink that accepts loss
- Set sink transaction timeouts above worst checkpoint plus recovery time
- Quote a broker setting as the pipeline's guarantee
- Derive dedup keys from a hash of the payload
- Pick 1 guarantee for every consumer of a stream
- Let consumer lag approach log retention without a page
- Assume exactly-once covers an external API call, whether it sends an email or fires a webhook
> A food delivery marketplace publishes order-placed events at 20,000 per second. One stream feeds restaurant payouts, courier dispatch, a demand heatmap for operations, and an order-confirmation push to the customer. After a regional outage, every consumer was down for 9 hours and restarted from its committed offset. The payout team finds 0 duplicates, dispatch notifies some couriers about orders already assigned, and the heatmap shows a demand spike that never happened.
Where the commit sits relative to the write decides every duplicate, every loss, and what exactly-once costs.
- Category
- Pipeline Architecture
- Difficulty
- advanced
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: "What Is Your Delivery Guarantee?", At-Least-Once and Its Duplicates, At-Most-Once: Fire and Forget, Exactly-Once and Its Cost, Semantics for a Business Need
Lesson Sections
- "What Is Your Delivery Guarantee?" (concepts: Idempotency)
Every delivery guarantee comes down to the order of 2 operations inside a consumer. One applies the effect: writing a row, calling an API, sending a message. The other records progress: committing the offset or checkpoint that says every record before this point is handled. A crash can land between them, and whichever ran first decides what the restart sees. The gap between effect and progress Run the 312-duplicate incident through the first row. The ledger consumer reads payment events in offse
- At-Least-Once and Its Duplicates (concepts: Idempotency)
At-least-once is the default of well-built pipelines because it fails in the recoverable direction: nothing is lost, and the price is repeats. The engineering question is where repeats enter and whether every sink downstream absorbs them. Where duplicates come from A rebalance also fires when a consumer goes longer than its maximum allowed interval between polls, so a slow write can evict it and cause a replay with no crash at all. The session timeout covers a different failure: missed heartbeat
- At-Most-Once: Fire and Forget
At-most-once commits progress before the effect, or never confirms the effect at all. A crash in the gap drops the batch, and nothing downstream notices. In return, the write path never waits for an acknowledgment and keeps no dedup state or replay logic, so it runs faster and lighter. When losing a record is the right call Weigh the cost of a duplicate against the cost of a loss at the volume the pipeline runs. At-most-once wins when a lost record barely moves the result, and a duplicate would
- Exactly-Once and Its Cost (concepts: Idempotency)
Crashes force re-execution, so no pipeline can stop a record from being processed twice. Exactly-once is a claim about effects: however many times the work runs, the sink shows each record once. It comes from 2 families of design, transactional commit or replay into an idempotent sink. Each sends a different bill. Transactions inside 1 system If the sink can store the consumer's progress, write both in 1 transaction. The ledger rows for offsets 48,000 through 48,999 and the row recording next of
- Semantics for a Business Need (concepts: Idempotency)
A single event stream usually feeds several consumers, and each needs its own guarantee. Picking 1 guarantee for "the pipeline" overpays on the dashboards or underprotects the money. Decide per sink, from 4 questions. The 4 questions also order your interview time: spend it on the sinks where both a duplicate and a loss are expensive, because those need the costliest mechanism. A stream with 4 consumers Take a payments company's charge events at 20,000 per second, read by 4 consumers, each at it