The previous lesson ended with three awkward questions. If inventory deducts the stock and dies before acknowledging the message, the broker redelivers it and the stock is deducted twice. If orders saves the order in PostgreSQL and crashes before publishing order.created, inventory and analytics will never know that the order exists. And if a malformed message makes the consumer fail over and over again, all the messages queued behind it are left waiting. None of the three is a rare case: they are direct consequences of the fallacies from 01-04 applied to messaging, and they will show up in production during the very first campaign.
This lesson presents the patterns with which the industry has learnt to live with them. First we will put a name to what a broker can and cannot guarantee (at-most-once, at-least-once and the myth of exactly-once), and see how those guarantees depend on where the ack is placed. Then we will finally close the duplicates problem that we left open in 01-04 and 02-02, with idempotent consumers backed by a table in PostgreSQL. We will solve the dual write with the transactional outbox pattern and a relay, implement request/reply over messaging, scale out with competing consumers while respecting per-partition ordering, and deal with poison messages using retries and dead letter queues. We will finish with event schema versioning and a table that maps each pattern to the problem it solves. Sagas (transactions across services) and the circuit breaker (resilience of synchronous calls) are left for 03-05 and 07-04.
Contents
- Delivery guarantees: at-most-once, at-least-once and the myth of exactly-once
- Idempotent consumers: closing the duplicates problem
- The dual write problem and the transactional outbox pattern
- Request/reply over messaging
- Competing consumers and per-partition ordering
- Poison messages: retries with backoff and dead letter queues
- Event-driven and event sourcing: two different things
- Event schemas and versioning
- Pattern table: which problem each one solves
- Common mistakes and tips
- Exercises
- Conclusion
- Delivery guarantees: at-most-once, at-least-once and the myth of exactly-once
In 02-02 we saw the invocation semantics of RPC. The delivery guarantees of messaging are the same idea with a broker in the middle, and the factor that decides them is when you acknowledge (ack in RabbitMQ, offset commit in Kafka) relative to when you process:
sequenceDiagram
participant B as Broker
participant C as Consumer (inventory)
participant DB as PostgreSQL
Note over B,DB: Option A: acknowledge BEFORE processing → at-most-once
B->>C: order.created (offset 41)
C->>B: commit(41)
C-xDB: UPDATE stock ... (the process dies here)
Note over B,DB: The broker believes 41 is done: the message is LOST
Note over B,DB: Option B: acknowledge AFTER processing → at-least-once
B->>C: order.created (offset 42)
C->>DB: UPDATE stock ... COMMIT
C-xB: commit(42) (the process dies here)
Note over B,DB: The broker redelivers 42: the stock is deducted TWICE
| Guarantee | How it is achieved | Risk | When it is acceptable |
|---|---|---|---|
| At-most-once | Acknowledge before processing (or never acknowledge, with auto_ack) |
Message loss | Telemetry, metrics, any data that the next message replaces |
| At-least-once | Acknowledge after processing; the producer retries until it receives confirmation from the broker | Duplicates | Anything that matters, provided the consumer is idempotent |
| Exactly-once | Does not exist end to end | — | It is emulated with at-least-once + idempotency |
The myth, stated precisely. Kafka offers a feature called exactly-once semantics (idempotent producers and Kafka transactions) which guarantees that a message is not duplicated inside Kafka: if the producer retries because of a timeout, the broker deduplicates; and an application that reads from one topic and writes to another can do so atomically. It is valuable for Kafka → Kafka pipelines (Module 5). But as soon as the effect of the message leaves Kafka (an UPDATE in PostgreSQL, an email, a charge), the window between "effect applied" and "offset committed" reappears, and nobody can close it from outside. The same goes for MQTT's QoS 2, which guarantees a single delivery to the client, not a single effect. The practical conclusion is the one from 02-02: at-least-once in the transport, idempotency in the consumer. Everything else in this lesson is built on that foundation.
- Idempotent consumers: closing the duplicates problem
An operation is idempotent if running it N times produces the same state as running it once. Some are idempotent by nature (SET stock = 118, "mark the order as paid", "insert with a primary key"); others are not (stock = stock - 2, "send an email", "charge €14.50"). For those that are not, the technique is to have the consumer remember which messages it has already processed and discard the repeats. Two requirements:
- An idempotency key per message: a unique identifier generated by the producer and stable across retries. It is the
event_idof the envelope from 02-04, and it was thereservation_idof the gRPC request in 02-03. Without it there is no way of knowing that two messages are "the same one". - Record the key and apply the effect in the same transaction. If the key is recorded in one transaction and the effect applied in another, the window reappears: the key is recorded, the process dies, the effect never happens, and the retry is discarded as a duplicate (loss). If it is the other way round, the effect is applied, the process dies, and the retry applies it again (duplicate). With both in the same transaction, either both happen or neither does.
inventory already has its own database (one of the principles of 01-06), so we add the table there:
-- km0/sql/inventory/002_processed_messages.sql
CREATE TABLE stock (
product TEXT PRIMARY KEY,
units INTEGER NOT NULL CHECK (units >= 0)
);
INSERT INTO stock VALUES ('pink-tomato', 120), ('zucchini', 80), ('aged-cheese', 5),
('fresh-cheese', 30), ('crianza-wine', 200);
CREATE TABLE processed_messages (
message_id TEXT NOT NULL, -- event_id from the envelope
consumer TEXT NOT NULL, -- 'inventory.stock_deduction'
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, consumer)
);The composite primary key allows two different pieces of logic inside inventory (deducting stock and, say, notifying the producer) to each process the same event once. The Kafka consumer from 02-04, now idempotent, with psycopg (version 3):
# km0/services/inventory/idempotent_consumer.py
import json
import psycopg
from confluent_kafka import Consumer, KafkaError
CONSUMER = "inventory.stock_deduction"
DSN = "postgresql://km0:km0_dev@localhost:5432/km0_inventory"
class InsufficientStock(Exception):
pass
def process_order_created(conn, event):
"""Applies the stock deduction ONLY once per event_id.
Returns 'processed' or 'duplicate'. Raises if the effect cannot be applied."""
with conn.transaction(): # BEGIN ... COMMIT/ROLLBACK
with conn.cursor() as cur:
# 1. Try to record the key. If it already exists, ON CONFLICT does not insert
# and RETURNING returns no row: it is a duplicate.
cur.execute(
"INSERT INTO processed_messages (message_id, consumer) VALUES (%s, %s) "
"ON CONFLICT DO NOTHING RETURNING message_id",
(event["event_id"], CONSUMER))
if cur.fetchone() is None:
return "duplicate"
# 2. Apply the effect in THE SAME transaction.
for line in event["data"]["lines"]:
cur.execute(
"UPDATE stock SET units = units - %s "
"WHERE product = %s AND units >= %s",
(line["quantity"], line["product"], line["quantity"]))
if cur.rowcount == 0:
# This also rolls back the record from step 1: the message is NOT
# marked as processed and can be retried or sent to the DLQ (sec. 6)
raise InsufficientStock(f"no stock of {line['product']}")
return "processed"
def main():
consumer = Consumer({"bootstrap.servers": "localhost:9092", "group.id": "inventory",
"auto.offset.reset": "earliest", "enable.auto.commit": False})
consumer.subscribe(["orders.events"])
with psycopg.connect(DSN) as conn:
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() != KafkaError._PARTITION_EOF:
print("[inventory] Kafka error:", msg.error())
continue
event = json.loads(msg.value())
if event["type"] == "order.created":
result = process_order_created(conn, event)
print(f"[inventory] {event['event_id'][:8]} order {event['data']['id']}: {result}")
consumer.commit(message=msg) # ALWAYS after the DB transaction
if __name__ == "__main__":
main()Let's walk through the possible failures with this code:
- It dies after the PostgreSQL
COMMITand before the Kafkacommit. Kafka redelivers the message; theINSERTcollides with the primary key;ON CONFLICT DO NOTHINGdoes not insert;fetchone()returnsNone; the answer isduplicatewithout touching the stock; the offset is committed. No duplication. - It dies in the middle of the transaction. PostgreSQL does a
ROLLBACK: neither the key nor the deduction persists. Kafka redelivers; it is processed from scratch. No loss. - It dies before starting. Kafka redelivers. Trivial.
- The producer published the same event twice (it retried because of a broker timeout). Same
event_id, same outcome: the second copy is a duplicate. This is the situation from the simulation in 01-04, now solved: the retry no longer turns the loss of a message into duplicated effects.
The same applies to the synchronous call from 02-03: ReserveStock can record reservation_id in processed_messages with consumer = 'inventory.grpc_reservation' and return the stored response if the call is repeated, so the "unknown status" after a DEADLINE_EXCEEDED stops being a problem: orders can retry with the same reservation_id without risk. It is the at-most-once semantics of 02-02 built with a table. Two operational considerations: the table grows (one row per message) and must be purged by age (beyond the topic's retention period no retry can arrive any more: DELETE ... WHERE processed_at < now() - interval '14 days'); and if the consumer has no transactional database (for example, it sends emails), idempotency has to rely on the target system (an email provider that accepts an idempotency key) or settle for at-most-once.
- The dual write problem and the transactional outbox pattern
Now for the producer's side. In 02-04, orders did two things when creating an order: write it to its PostgreSQL database and publish order.created to Kafka. These are two different systems and there is no transaction that spans both (this is the dual write):
- If it writes to the database and crashes before publishing, the order exists but nobody finds out:
inventorydoes not deduct,analyticsdoes not count,deliverydoes not deliver. - If it publishes first and then the
COMMITfails, everyone reacts to an order that does not exist. - If it publishes inside the transaction "so that it gets rolled back", it does not get rolled back: Kafka takes no part in PostgreSQL's
ROLLBACK.
The transactional outbox pattern solves the problem by turning two writes into one: the event is written to a table in the same database, in the same transaction as the order. A separate process, the relay, reads that table and publishes to Kafka.
sequenceDiagram
participant A as Anna's app
participant P as orders
participant DB as PostgreSQL (orders)
participant R as Outbox relay
participant K as Kafka
participant I as inventory
A->>P: create order
P->>DB: BEGIN
P->>DB: INSERT orders, order_lines
P->>DB: INSERT outbox (order.created)
P->>DB: COMMIT (atomic: order + event, or nothing)
P-->>A: order P-2026-000123 created
loop every 200 ms
R->>DB: SELECT ... FROM outbox WHERE published_at IS NULL FOR UPDATE SKIP LOCKED
R->>K: produce(order.created)
K-->>R: confirmed
R->>DB: UPDATE outbox SET published_at = now()
end
K->>I: order.created (at-least-once)
The table, in the orders database:
-- km0/sql/orders/002_outbox.sql
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- this will be the event_id
aggregate TEXT NOT NULL, -- 'order'
aggregate_id TEXT NOT NULL, -- 'P-2026-000123': partition key
type TEXT NOT NULL, -- 'order.created'
version INTEGER NOT NULL DEFAULT 1,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_pending ON outbox (created_at) WHERE published_at IS NULL;The write in orders:
# km0/services/orders/create_order.py
import json
import uuid
import psycopg
DSN = "postgresql://km0:km0_dev@localhost:5432/km0_orders"
def create_order(conn, order):
"""Saves the order AND its event in a single transaction. No Kafka here."""
event_id = uuid.uuid4()
with conn.transaction():
with conn.cursor() as cur:
cur.execute("INSERT INTO orders (id, customer, market, status) VALUES (%s, %s, %s, 'created')",
(order["id"], order["customer"], order["market"]))
for line in order["lines"]:
cur.execute("INSERT INTO order_lines (order_id, product, quantity, price_cents) "
"VALUES (%s, %s, %s, %s)",
(order["id"], line["product"], line["quantity"], line["price_cents"]))
cur.execute("INSERT INTO outbox (id, aggregate, aggregate_id, type, payload) "
"VALUES (%s, 'order', %s, 'order.created', %s)",
(event_id, order["id"], json.dumps(order)))
return event_id
if __name__ == "__main__":
with psycopg.connect(DSN) as conn:
create_order(conn, {"id": "P-2026-000123", "customer": "anna", "market": "girona",
"lines": [{"product": "aged-cheese", "quantity": 2, "price_cents": 1450}]})And the relay, an independent process that can run as several instances thanks to FOR UPDATE SKIP LOCKED (each one locks different rows):
# km0/services/orders/outbox_relay.py
import json
import time
import psycopg
from confluent_kafka import Producer
DSN = "postgresql://km0:km0_dev@localhost:5432/km0_orders"
TOPIC = "orders.events"
BATCH = 100
producer = Producer({"bootstrap.servers": "localhost:9092", "acks": "all"})
def publish_pending(conn):
"""Returns how many events it has published in this pass."""
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT id, aggregate_id, type, version, payload, created_at FROM outbox "
"WHERE published_at IS NULL ORDER BY created_at "
"LIMIT %s FOR UPDATE SKIP LOCKED", (BATCH,))
rows = cur.fetchall()
for event_id, aggregate_id, type_, version, payload, created_at in rows:
envelope = {"event_id": str(event_id), "type": type_, "version": version,
"timestamp_ms": int(created_at.timestamp() * 1000),
"source": "orders", "data": payload}
producer.produce(TOPIC, key=aggregate_id, value=json.dumps(envelope).encode(),
headers=[("type", type_), ("event_id", str(event_id))])
producer.flush() # waits for Kafka's confirmation
if rows:
cur.execute("UPDATE outbox SET published_at = now() WHERE id = ANY(%s)",
([r[0] for r in rows],))
return len(rows)
if __name__ == "__main__":
with psycopg.connect(DSN) as conn:
while True:
n = publish_pending(conn)
if n == 0:
time.sleep(0.2) # nothing to do: short wait
else:
print(f"[relay] published {n} events")Let's examine the relay under the same magnifying glass as the consumer. If it dies after flush() and before the UPDATE, the transaction is rolled back, the rows are still pending and on the next pass they are published again: the relay is at-least-once, and it produces duplicates with the same event_id (the id of the outbox row). Those duplicates are absorbed by the idempotent consumer from section 2. The two patterns need each other: the outbox guarantees that no event is lost; idempotency guarantees that none is applied twice. And ORDER BY created_at together with the aggregate_id partition key preserves the order of each order's events.
CDC as an alternative to the relay. Instead of a process that polls the table, Change Data Capture (CDC) tools such as Debezium read PostgreSQL's write-ahead log and publish every insert into outbox to Kafka with millisecond latency and no repeated queries. It is the recommended outbox implementation at scale; the polling relay is easier to understand and good enough to start with. We only mention it: the mechanics of the replication log belong to 03-04.
- Request/reply over messaging
Sometimes you want the temporal decoupling of messaging but you need an answer: an internal service that asks payments to authorise a card and wants the result, even if it takes seconds. The pattern uses two properties of the AMQP message:
reply_to: the name of the queue where the requester waits for the reply (often an exclusive, temporary queue that it has created itself).correlation_id: an identifier that the requester puts in the request and the server copies into the reply, so that the requester can match replies to requests when it has several in flight.
# km0/services/orders/payments_rpc_client.py (excerpt: just the sending and the waiting)
import json
import uuid
import pika
connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()
# Reply queue exclusive to this process; the broker deletes it on disconnect
reply_queue = channel.queue_declare(queue="", exclusive=True).method.queue
replies = {}
def on_reply(ch, method, props, body):
replies[props.correlation_id] = json.loads(body)
channel.basic_consume(queue=reply_queue, on_message_callback=on_reply, auto_ack=True)
def authorize_card(order_id, amount_cents, timeout_s=5.0):
corr_id = str(uuid.uuid4())
channel.basic_publish(
exchange="", routing_key="payments.authorizations", # payments' work queue
properties=pika.BasicProperties(reply_to=reply_queue, correlation_id=corr_id,
content_type="application/json"),
body=json.dumps({"order_id": order_id, "amount_cents": amount_cents}))
# Bounded busy wait: processes channel events until the reply arrives
import time
end = time.monotonic() + timeout_s
while corr_id not in replies:
if time.monotonic() > end:
raise TimeoutError(f"payments did not reply to {corr_id} within {timeout_s} s")
connection.process_data_events(time_limit=0.1)
return replies.pop(corr_id)On the payments side, the consumer processes the request and publishes the reply to props.reply_to with correlation_id=props.correlation_id. (With BlockingConnection, process_data_events(time_limit=...) is the way to handle the replies that arrive while waiting; in an asynchronous client the loop would be different, but the idea is the same.) This pattern keeps the advantages of messaging (if payments is overloaded, requests wait in the queue instead of overloading it further; if it restarts, they are not lost) in exchange for more complexity than a gRPC call. At Kilometre Zero it will be used sparingly: for quick queries, gRPC (02-03) is simpler; for consequences, plain events. Its place is slow or spiky operations where you want an answer but do not want to block the server.
- Competing consumers and per-partition ordering
When inventory cannot keep up during "Artisan Cheese Week", the solution is to start more instances that share the queue (RabbitMQ) or the group (Kafka): competing consumers. Each message is processed by a single instance, and throughput scales with the number of instances... up to a limit, which is different for each broker:
- In RabbitMQ, any number of instances can compete for the same queue, but spreading messages among them means ordering is lost: Anna's
order.createdmay go to instance 1 andorder.cancelledto instance 2, which may process it first. If ordering matters, you have to serialise some other way (one queue per key, or the consistent hash exchange plugin). - In Kafka, the limit is the number of partitions (02-04) and ordering is guaranteed within each partition: all the events for
P-2026-000123are processed by the same instance in order, while those of other orders are spread around. This is the main reason why Kilometre Zero's domain events go to Kafka.
flowchart LR
K[(orders.events<br/>6 partitions)]
K -- "p0, p1" --> I1[inventory 1]
K -- "p2, p3" --> I2[inventory 2]
K -- "p4, p5" --> I3[inventory 3]
note["key P-2026-000123 → always p2 → always inventory 2 → in order"]
The price of per-partition ordering is head-of-line blocking of the whole partition (the same phenomenon as in TCP, 02-01): if the message at offset 41 of partition 2 takes ten seconds, offsets 42 onwards in that partition wait, even if they belong to other orders. Hence the importance of the consumer not getting stuck on one message, which is the subject of the next section.
- Poison messages: retries with backoff and dead letter queues
A poison message is one that makes the consumer fail every time it tries: malformed JSON, a product that does not exist in stock, a bug in the consumer triggered by certain data. With at-least-once and nothing else, the broker redelivers it indefinitely and the consumer is stuck in a loop, blocking its partition or its queue. Two classes of failure need to be distinguished:
- Transient: the database is not responding, a remote service times out. Retrying after a wait makes sense, and the wait should grow (exponential backoff: 1 s, 2 s, 4 s, 8 s...) so as not to hammer a sick system.
- Permanent: invalid data, a business rule violation (
InsufficientStockfrom section 2), a bug. Retrying changes nothing; the message must be set aside so that the rest can move on, and someone has to look at it.
The destination for messages that are set aside is the dead letter queue (DLQ). In RabbitMQ it is native: you declare the queue with a dead-letter exchange and, when a message is rejected without requeueing, the broker moves it there.
# RabbitMQ: queue with a native DLQ
channel.exchange_declare(exchange="km0.dlx", exchange_type="direct", durable=True)
channel.queue_declare(queue="inventory.orders.dlq", durable=True)
channel.queue_bind(queue="inventory.orders.dlq", exchange="km0.dlx", routing_key="inventory.orders")
channel.queue_declare(queue="inventory.orders", durable=True, arguments={
"x-dead-letter-exchange": "km0.dlx",
"x-dead-letter-routing-key": "inventory.orders",
})
def on_message(ch, method, props, body):
try:
process(body)
ch.basic_ack(delivery_tag=method.delivery_tag)
except PermanentError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False) # → DLQ
except TransientError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True) # back to the queue(An unbounded requeue=True can degenerate into the loop; in RabbitMQ the practice is to count attempts in a header, x-death, and to use a wait queue with a TTL for the backoff. The idea is the same as the one we are about to implement in Kafka.)
Kafka has no native DLQ: it is built with retry topics and a dead letter topic, and the consumer decides which one to send each failure to:
sequenceDiagram
participant T as orders.events
participant C as inventory
participant R as orders.events.retry
participant D as orders.events.dlq
participant O as Operator
T->>C: event (attempt 1)
C--xC: TransientError (DB not responding)
C->>R: event + headers {attempts: 1, not_before: t+1s}
C->>T: commit (the partition moves on: no blocking)
R->>C: event (waits until not_before; attempt 2)
C--xC: TransientError
C->>R: event {attempts: 2, not_before: t+2s}
R->>C: event (attempt 3)
C--xC: TransientError (maximum reached)
C->>D: event {attempts: 3, reason: "..."}
D->>O: alert: 1 message in the DLQ
O->>T: after fixing the cause, republishes from the DLQ
# km0/services/inventory/consume_with_retries.py (excerpt)
import json
import time
MAX_ATTEMPTS = 3
BASE_DELAY_S = 1.0
TOPIC, RETRY, DLQ = "orders.events", "orders.events.retry", "orders.events.dlq"
class TransientError(Exception): ...
class PermanentError(Exception): ...
def header(msg, name, default):
for k, v in (msg.headers() or []):
if k == name:
return v.decode()
return default
def consume_with_retries(msg, handler, producer):
attempts = int(header(msg, "attempts", "0"))
try:
handler(json.loads(msg.value()))
except TransientError as e:
if attempts + 1 < MAX_ATTEMPTS:
delay = BASE_DELAY_S * (2 ** attempts) # 1, 2, 4 seconds
producer.produce(RETRY, key=msg.key(), value=msg.value(), headers=[
("attempts", str(attempts + 1)), ("not_before", str(time.time() + delay)),
("last_error", str(e)[:200])])
else:
producer.produce(DLQ, key=msg.key(), value=msg.value(), headers=[
("attempts", str(attempts + 1)), ("reason", f"transient exhausted: {e}"[:200])])
except PermanentError as e:
producer.produce(DLQ, key=msg.key(), value=msg.value(), headers=[
("attempts", str(attempts + 1)), ("reason", f"permanent: {e}"[:200])])
producer.flush()
# In every case the offset on the source topic is committed: the message is already
# safe in retry or in dlq, and the partition is not blocked.A consumer of the .retry topic reads each message, sleeps until not_before if necessary, and processes it with the same function. Considerations that are not visible in the code: the message that goes to .retry loses its place in the order of the original partition (it will be processed after other, more recent ones), which is acceptable for a stock deduction and may not be for a created → cancelled sequence; the DLQ needs alerts (07-01) and a procedure for examining, fixing and republishing; and InsufficientStock from section 2 is a good example of a permanent error whose resolution is not technical but a business matter (notify the customer, cancel the order), which is the territory of the sagas of 03-05. Retries, backoff and circuit breakers for synchronous calls are covered in 07-04; here we have only seen those that belong to message consumption.
- Event-driven and event sourcing: two different things
Everything we have done in 02-04 and in this lesson is event-driven architecture: services communicate facts that have happened (order.created) and others react. Each service's state still lives in its tables (orders, stock); the events are notifications.
Event sourcing is something else, which is often confused with the above: it means that the state is not stored; the events are, and the state is rebuilt by replaying them. Anna's order would not be a row with status = 'paid', but the sequence OrderCreated, LineAdded, OrderPaid, and the row would be a derived projection. It gives you a complete audit trail and lets you rebuild any past state, at the cost of considerable complexity (projections, snapshots, evolution of historical events). You can be event-driven without event sourcing (Kilometre Zero is) and vice versa. We mention it so that the terms do not get mixed up; developing it is beyond the scope of this course.
- Event schemas and versioning
An event is a contract between the producer and all present and future consumers, including those that will read the Kafka history six days from now. The schema evolution rules of 02-03 apply to it even more strictly, because you cannot know how many consumers there are or force them to upgrade. The standard envelope we have been using already provides the mechanism:
{
"event_id": "6f1c9e2a-...",
"type": "order.created",
"version": 2,
"timestamp_ms": 1789000000000,
"source": "orders",
"data": {
"id": "P-2026-000123",
"customer": "anna",
"lines": [{"product": "aged-cheese", "quantity": 2, "price_cents": 1450}],
"delivery_address": {"city": "Girona", "postal_code": "17001"}
}
}The rules, adapted from 02-03:
- Compatible changes (do not increment
version): adding optional fields todata; adding new event types. Consumers must ignore unknown fields and tolerate the absence of the new ones. - Incompatible changes (increment
version): removing or renaming a field, changing its type or its meaning. During the transition, the producer can publish both versions or the consumers can accept both (if event["version"] == 1: ...). The meaning of a field is never changed while keeping its name. - Schema Registry: with Avro or protobuf on Kafka, a central service stores every version of each topic's schema, validates on the producer side that a change is compatible (backward, forward or both, depending on the policy) and lets consumers deserialize old messages with the schema they were written with. It is the way to turn the rules above into an automatic check, and the natural next step when JSON falls short. Kilometre Zero starts with JSON and the envelope; the schema registry is introduced with the data pipeline of Module 5.
- Pattern table: which problem each one solves
| Pattern | Problem it solves | Cost | Where it is used at Kilometre Zero |
|---|---|---|---|
| At-least-once + ack after processing | Message loss when the consumer dies | Duplicates | All consumers of domain events |
Idempotent consumer (processed_messages table) |
Duplicates caused by retries from the producer, the relay or the broker | One row per message; periodic purge | inventory (stock deduction and gRPC ReserveStock), payments (charges) |
| Transactional outbox + relay | Dual write: state saved without an event, or an event without state | One more process; latency of milliseconds to seconds | orders (all its events); later, every service that publishes |
| CDC (Debezium) | Polling relay at scale | Additional infrastructure | When polling is no longer enough |
Request/reply (reply_to, correlation_id) |
Needing an answer with the cushioning of a queue | Complexity compared with gRPC | Slow, spiky operations (payments authorisations) |
| Competing consumers | One consumer cannot keep up | Loss of ordering (RabbitMQ) or a limit set by partitions (Kafka) | All services during a campaign |
| Partition key | Ordering between events of the same entity | Per-partition head-of-line blocking; unbalanced partitions | orders.events by order id; telemetry by courier |
| Retries with backoff on consumption | Transient consumer failures | The retried message loses its ordering | All consumers |
| Dead letter queue | Poison messages that block the queue or the partition | Needs alerts and a reprocessing procedure | All consumers |
| Envelope + event versioning | Evolving the contract without coordinating deployments | Discipline and change review | All events |
Common Mistakes and Tips
- Looking for exactly-once in the broker configuration. It is not there. It is in the idempotent consumer, and there is no shortcut.
- Recording the idempotency key outside the effect's transaction. It reopens the window you were trying to close. Same transaction, always.
- Using something that changes between retries as the idempotency key. If the producer generates a new
event_idon every retry, the consumer cannot recognise the duplicate. The key is generated once and reused. - Publishing to Kafka "inside" the PostgreSQL transaction. The
ROLLBACKdoes not undo it. Outbox or nothing. - A relay that marks rows as published before the
flush. If Kafka does not confirm, the event is lost for ever with the row marked. Confirmation first, mark afterwards. - Retrying permanent errors. Malformed JSON does not fix itself by waiting 4 seconds; it blocks the partition three more times. Classify your errors and send the permanent ones to the DLQ at the first attempt.
- A DLQ with no alerts and no owner. It is a black hole where orders silently disappear. Every DLQ needs an alert, a person and a procedure.
- Changing the meaning of a field without changing the version. A consumer that re-reads the history will read data with two meanings under the same name. New field or new version.
- Tip: test idempotency explicitly: in the test environment, publish every event twice and check that the final state is the same. It is the cheapest test there is and the one that prevents the most incidents.
- Tip: measure the lag of the relay (
outboxrows withpublished_at IS NULLand their age) and of each consumer group. They are the two indicators that tell you whether asynchrony is working or piling up invisible debt.
Exercises
Exercise 1: Auditing a consumer
This payments consumer processes order.created and runs the charge against the external gateway. Identify all the delivery-guarantee problems it has, say what can go wrong in each case (loss, duplicate, blocking) and rewrite it applying the patterns from the lesson. Assume that the gateway accepts an Idempotency-Key header and that payments has its own PostgreSQL database.
consumer = Consumer({"bootstrap.servers": "localhost:9092", "group.id": "payments",
"enable.auto.commit": True})
consumer.subscribe(["orders.events"])
while True:
msg = consumer.poll(1.0)
if msg is None or msg.error():
continue
event = json.loads(msg.value())
if event["type"] == "order.created":
amount = sum(l["quantity"] * l["price_cents"] for l in event["data"]["lines"])
response = gateway.charge(card=event["data"]["card"], amount=amount)
with psycopg.connect(DSN) as conn:
conn.execute("INSERT INTO payments (order_id, amount, gateway_ref) VALUES (%s, %s, %s)",
(event["data"]["id"], amount, response["ref"]))Exercise 2: An outbox for inventory
inventory must publish stock.updated (with product, before, after, reason) every time the stock changes, whether through the gRPC reservation from 02-03 or through the consumption of order.created in section 2. Design the outbox table for inventory, modify process_order_created to write the event in the same transaction, and explain what the combination (idempotency + outbox) guarantees in the face of each of these failures: (a) the consumer dies between the COMMIT and the offset commit; (b) the relay dies after publishing and before marking; (c) Kafka is unavailable for 10 minutes.
Exercise 3: Designing the retry and DLQ policy
For the delivery consumer that assigns a courier to each order.paid, classify each of these failures as transient or permanent, say where the message goes (retry with backoff, DLQ) and what the operator does in each case: (1) the external maps service returns 503; (2) the order has a delivery address in a city where Kilometre Zero does not deliver; (3) KeyError: 'delivery_address' on a version: 1 event; (4) the delivery database refuses the connection during a restart; (5) there is no free courier in Tarragona right now.
Solutions
Solution 1:
Problems: (1) enable.auto.commit=True commits offsets periodically regardless of whether the message has been processed: if the process dies after the auto-commit and before charging, the charge is lost (at-most-once); if it dies after charging and before the auto-commit, the customer is charged twice (at-least-once without idempotency). (2) The charge to the gateway and the INSERT are two writes with no common transaction: if the gateway charges and the INSERT fails, there is a charge with no record; on retry, another charge. (3) There is no idempotency key either towards the gateway or in the database: the same order.created delivered twice charges Anna twice. (4) msg.error() is silently ignored. (5) Any exception (malformed JSON, invalid card) kills the loop or, if it were caught, would block the partition. (6) It does not publish payment.confirmed, so nobody finds out about the charge (a dual write still to be dealt with). Rewrite:
consumer = Consumer({"bootstrap.servers": "localhost:9092", "group.id": "payments",
"auto.offset.reset": "earliest", "enable.auto.commit": False})
consumer.subscribe(["orders.events"])
def charge_order(conn, event):
order = event["data"]
with conn.transaction():
with conn.cursor() as cur:
cur.execute("INSERT INTO processed_messages (message_id, consumer) VALUES (%s, 'payments.charge') "
"ON CONFLICT DO NOTHING RETURNING message_id", (event["event_id"],))
if cur.fetchone() is None:
return "duplicate"
amount = sum(l["quantity"] * l["price_cents"] for l in order["lines"])
# The gateway deduplicates by Idempotency-Key: using the order id means that
# a retry (even with a different event_id) does not charge twice.
response = gateway.charge(card=order["card"], amount=amount,
idempotency_key=f"charge-{order['id']}")
cur.execute("INSERT INTO payments (order_id, amount, gateway_ref) VALUES (%s, %s, %s)",
(order["id"], amount, response["ref"]))
cur.execute("INSERT INTO outbox (id, aggregate, aggregate_id, type, payload) "
"VALUES (%s, 'payment', %s, 'payment.confirmed', %s)",
(uuid.uuid4(), order["id"], json.dumps({"order_id": order["id"], "amount": amount})))
return "processed"
with psycopg.connect(DSN) as conn:
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
if msg.error():
print("Kafka error:", msg.error()); continue
try:
event = json.loads(msg.value())
except json.JSONDecodeError as e:
to_dlq(msg, f"permanent: {e}"); consumer.commit(message=msg); continue
if event["type"] == "order.created":
try:
charge_order(conn, event)
except GatewayUnavailable as e:
to_retry(msg, e) # transient: backoff
except CardDeclined as e:
to_dlq(msg, f"permanent: {e}") # business: order to be cancelled (03-05)
consumer.commit(message=msg)One unavoidable window remains: if the process dies between gateway.charge and the COMMIT, the transaction is rolled back (no key, no record), the message is retried, and the second charge with the same Idempotency-Key makes the gateway return the charge already made instead of repeating it. The external system's idempotency closes what the local transaction cannot cover. Without that header, we would have to record "charge started" before calling and reconcile afterwards: that is the situation in which the sagas of 03-05 become necessary.
Solution 2:
CREATE TABLE outbox (
id UUID PRIMARY KEY, aggregate TEXT NOT NULL, aggregate_id TEXT NOT NULL,
type TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), published_at TIMESTAMPTZ);With aggregate = 'product' and aggregate_id = product, so that the changes to a given product go in order to the same partition of inventory.events. In process_order_created, inside the same with conn.transaction(), after each successful UPDATE:
cur.execute("SELECT units FROM stock WHERE product = %s", (line["product"],))
after = cur.fetchone()[0]
cur.execute("INSERT INTO outbox (id, aggregate, aggregate_id, type, payload) VALUES (%s, 'product', %s, 'stock.updated', %s)",
(uuid.uuid4(), line["product"],
json.dumps({"product": line["product"], "before": after + line["quantity"],
"after": after, "reason": "order", "order_id": event["data"]["id"]})))(And the same in ReserveStock on the gRPC server, which would stop using the in-memory dictionary and use the same database instead.) Guarantees: (a) the consumer dies between the COMMIT and the offset commit: the stock has been deducted and the event is in outbox; the relay will publish it; the redelivered message is detected as a duplicate and generates neither a deduction nor a second event. (b) The relay dies after publishing and before marking: the event is published twice with the same event_id; catalog and analytics, if they are idempotent, ignore it the second time; if catalog only does SET indicator = (after < 5), it is idempotent by nature and does not even need the table. (c) Kafka down for 10 minutes: inventory carries on processing (if it reads from RabbitMQ) or stops (if it reads from Kafka), but in both cases it loses nothing: the events pile up in outbox with published_at IS NULL and the relay publishes them in order when Kafka comes back. It is exactly the behaviour asked for in solution 3.3 of lesson 01-06, now implemented.
Solution 3:
- Transient: 503 means "come back later". Retry with backoff (1, 2, 4 s...) up to the maximum; after that, DLQ. The operator checks the status of the maps provider; once it recovers, they republish the DLQ.
- Permanent, business-related: no retry will fix it. DLQ with reason
city_not_covered, and probably adelivery.rejectedevent so thatorderscancels andpaymentsrefunds (a saga, 03-05). The operator or, better still, validation inorderswhen the order is created, should have prevented it earlier: the DLQ reveals a gap in the validation. - Permanent, contract-related: the consumer assumes the
version: 2schema and receives aversion: 1one (probably from the history or from anordersthat has not yet been upgraded). DLQ with reasonschema, an alert to the team, and the fix goes in the consumer (accept both versions, section 8); once it is deployed, the messages in the DLQ are republished. It is not a data failure but that of a consumer that did not follow the compatibility rules. - Transient: database restart. Retry with backoff. Here it is especially important not to commit the offset without first having moved the message to
.retry; with the database down, idempotency cannot even be recorded, so the message must remain intact to be retried. - Transient, but business-related and long-lasting: it may take 20 minutes for a courier to become free. A backoff measured in seconds does not fit; it is better for the consumer to process the message by recording the order as "pending assignment" in its database (idempotently) and commit, and for a periodic process to try to assign the pending ones. Turning a long wait into persisted state is preferable to keeping messages bouncing between retry topics, which would also lose their ordering relative to a possible later
order.cancelled.
Conclusion
This lesson has closed the module by solving the problems that the previous ones kept leaving open. Delivery guarantees depend on where you acknowledge relative to where you process: acknowledging before loses messages (at-most-once), acknowledging after duplicates them (at-least-once), and exactly-once does not exist end to end, so the strategy is at-least-once in the transport and idempotency in the consumer. The idempotent inventory consumer, with its processed_messages table in the same transaction as the effect, has closed the duplicates problem we had been carrying since the simulation in 01-04 and the semantics of 02-02, and the same technique makes retries of the gRPC call from 02-03 safe. The outbox pattern, with its FOR UPDATE SKIP LOCKED relay, has eliminated the dual write in orders: state and event are saved together or not at all. We have also seen request/reply over queues with reply_to and correlation_id, competing consumers with per-partition ordering, retries with backoff, dead letter queues with their alerts and their procedure, the difference between event-driven and event sourcing, and event versioning with the standard envelope and the rules inherited from 02-03. The table in section 9 summarises which pattern solves which problem and where Kilometre Zero uses it.
With this, Module 2 has kept its promise: orders and inventory are two processes that talk to each other reliably, synchronously over gRPC when an answer is needed and asynchronously over Kafka for the consequences, and analytics listens without anyone having to wait for it. But look at what has happened along the way: inventory now has its own stock table and orders its own orders table, and the truth about Montblanc Dairy's last aged cheese is no longer in a single place. When Anna reserves it and catalog receives the stock.updated event half a second later, for that half second Mark sees a cheese in the catalogue that no longer exists. When inventory has the inv-bcn and inv-vlc replicas, which of the two is right if they differ? What exactly does "consistent" mean when the data lives in several places, and what can be promised to a customer when the network between them fails? These are the questions of Module 3, Consistency and Replication, which begins with consistency models: the precise vocabulary for saying what a distributed system guarantees about its data and what it does not.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
