keystone/Delivery semantics

Delivery Semantics: Why Exactly-Once Is Something You Build

Message systems can retry, persist, and order work, but the final business effect is correct only when the producer, broker, consumer, and side-effect store agree on failure behavior.

TLDR

Quick brief

Mental Model

A consumer loop has two obligations for each message: apply the effect and move the bookmark. A crash can land between them. The order you choose decides whether restart skips work, repeats work, or repeats work safely because the effect is idempotent.

Three timelines comparing at-most-once, at-least-once, and idempotent effect behavior when a consumer crashes between effect and progress recording.

The broker can reduce failure windows, but it cannot remove the one between an external effect and the offset that says the message is done. That boundary belongs to application design.

Ground-Up Explanation

Producer to broker

A producer that times out waiting for an acknowledgement cannot know whether the broker missed the message or the acknowledgement was lost. Retrying is therefore required for durability, and retries imply duplicates unless the broker can recognize a repeated send. Kafka's idempotent producer uses producer identity, epoch, and per-partition sequence numbers to dedupe producer retries within a session.

Ack settings define the producer-side promise. acks=0 means no broker promise. acks=1 means the leader wrote locally. acks=all, with enough in-sync replicas, means the record reached the configured replication threshold. A weak producer guarantee can lose a record before consumers even start.

Broker to consumer

The consumer side is about offset timing. Commit before processing gives at-most-once behavior: the message may be skipped after a crash. Commit after processing gives at-least-once behavior: the message may be replayed after a crash. Automatic commits are convenient but make the timing less explicit; manual commits force the application to choose where progress becomes durable.

auto.offset.reset only applies when no committed offset exists. It is not a recovery policy for normal crashes. The normal recovery point is whatever offset was last committed to the broker's offset store.

Concept Deep Dive

Idempotency strategy catalog

StrategyHow it worksWhen it fits
Natural idempotencyUse operations where repeating the same command has the same final state, such as setting a status or using an upsert.State transitions that can be expressed as assignment, not accumulation.
Dedup tableInsert a unique event id and apply the side effect in the same database transaction.Most durable consumers that write to a relational store.
Conditional writeApply only if version, state, or sequence number matches the expected previous value.Workflows with explicit state machines or monotonic versions.
API idempotency keyCaller supplies a stable key; server stores request outcome and returns the same result for retries.External request boundaries where clients retry after ambiguous failures.

The dedup-table rule depends on one detail: the duplicate check and the side effect must commit in the same transaction. Checking one store and mutating another recreates the same crash window one layer down.

Idempotent consumer flow showing a broker event, consumer, database transaction with dedup insert and effect write, database commit, then offset commit.

Ordering and dedup interact

Ordering is usually per key, not global. A version guard works well when every event for one entity is processed in order. Out-of-order redelivery complicates that guard: an old event can arrive after a newer one, and a naive "not seen before" check may apply stale work. For ordered workflows, include per-entity sequence numbers or expected-state guards so duplicates and stale messages are separate cases.

Kafka transactions and EOS boundary

Kafka transactions can atomically write output records and commit consumed offsets. That makes consume-transform-produce pipelines exactly-once inside Kafka: either the transformed output and offset commit become visible together, or neither does. The boundary is any external side effect. A database row, email, payment-provider call, or HTTP mutation is not part of the Kafka transaction unless you add a separate idempotency or reconciliation mechanism.

Dedup retention and scope

Dedup state needs an explicit retention policy. It should cover the maximum replay horizon: broker retention, backup restore windows, manual replay habits, and connector retries. Scope the key to the effect. A globally unique event id is simple; (consumer_name, event_id) is safer when multiple consumers need to apply different effects from the same event.

Implementation Details

Production examples

Use at-most-once for data that is useful but disposable: metrics samples, typing indicators, cache-warming hints, or presence pings. Losing one update is acceptable because the next update replaces it or the aggregate still remains useful.

Use at-least-once plus idempotency for side effects that must happen but may be retried: creating a support ticket from an event, sending a customer notification, updating a search index, writing a projection table, or calling a provider that accepts an idempotency key. The duplicate should be visible in logs and metrics, but it should not create a second business effect.

Put the idempotency check at the side-effect boundary. A Kafka consumer that writes to Postgres should insert the processed event id in the same transaction as the row it changes. An HTTP API should store the idempotency key and response before returning. A worker that calls an external API should pass the provider's idempotency key and record the attempt so retries do not create a second remote action.

Lab Evidence

The runnable lab is labs/kafka/delivery: 1,000 payment events, each crediting 100 cents, applied to a Postgres balance by a consumer that hard-crashes (os.Exit, no cleanup) after event 500. The verify step compares the balance to the exact expected 100,000 cents.

make break

at-least-once: apply, crash, replay. Verify reports an overshoot and exits 1.

make lose

at-most-once: commit, crash, skip. Verify reports a shortfall and exits 1.

make test

idempotent: same crash, dedupe insert in the credit's transaction. Verify is exact, exit 0.

Measured: the same crash, three balances

From real runs on 2026-07-07 (1,000 events, crash near event 500, offsets batch-committed every 100 messages):

at-least-once:  applied=600 after restart (100 replayed)
                balance=110000, expected=100000 => overshoot 10000. exit 1

at-most-once:   CRASH after committing offset for pay-499, before applying it
                balance= 99900, expected=100000 => shortfall 100. exit 1

idempotent:     applied=500 skipped-as-duplicate=100
                balance=100000, expected=100000 => exact. exit 0

The overshoot matches the replay window: the last batch commit covered event 400, so 100 events replayed. The idempotent consumer replayed the same 100 and skipped them inside the credit's own transaction.

Production Notes

Code Pointers

CodeWhy it matters
cmd/delivery/main.goAll three semantics differ only in the placement of CommitMessages and one dedupe insert; see consume and apply.
migrations/001_ledger.sqlThe dedupe table: a primary key doing distributed-systems work.
README.mdScenario notes, expected balances, and how to read each failure mode.
MakefileThe at-most-once, at-least-once, and idempotent runs as explicit targets.
compose.yamlKafka and Postgres services used by the delivery-semantics lab.

Further Reading & Watching