Later, Not Never · Nº 17

Later, Not Never

A field guide to queues, events, and asynchronous systems

The moment work leaves the request/response cycle, you give up the one thing synchronous code guarantees: the caller knows what happened. Queues, acknowledgments, retries, idempotent consumers, and sagas are not separate gadgets — they are one coherent system for buying that certainty back, a piece at a time, at a price you can name. This guide teaches you which pieces exist, what each one costs, and how to verify you actually hold the guarantee you think you bought.

Module 01 The case for later

Synchronous code has one property nothing else in this guide can match: when the call returns, you know what happened. The work is done, or it demonstrably failed, and the caller holds that fact in its hand. Every argument for asynchrony is an argument for giving that up — and it is usually the right argument, because the same call that hands you certainty also chains your fate to the slowest, flakiest thing downstream of you.

This module makes the trade explicit in both directions. First the case for later: the three couplings a queue breaks, and how to tell which one your system actually suffers from rather than which one sounds most architectural. Then the bill: what the caller stops knowing the instant you return 202 Accepted, and why the remaining seven modules exist to buy specific fractions of that certainty back. Finally, the cases where later is simply wrong — because a queue in front of an authorization decision is not an architecture, it's a bug with a dashboard.

The tyranny of the request

Start with the wine-cellar product's price-alert feature, which is the running example for this entire course. A merchant feed updates a price; users who watch that wine at that threshold should get an email. In the first version, the whole thing lives inside one HTTP request: the feed webhook arrives, the handler matches alerts, calls the email provider, writes the price history row, and returns 200.

This works beautifully until any single part of it doesn't. Four failures are usually catalogued separately — slow work, a flaky downstream, a traffic spike, and fan-out to several consumers — but they are one failure wearing four costumes. In request/response, the caller's outcome is a function of every callee's outcome. You inherit their latency, their error rate, and their capacity ceiling, all at once, at the exact moment a user is watching a spinner.

The wine-cellar case makes the arithmetic concrete. Alert matching is fast: a keyed query, 300 ms at p99. The email provider is not: it is generally 400 ms, but once or twice a week it enters an eight-second brownout where requests hang and then fail. In the synchronous design, a brownout in a third-party mail API becomes a 502 on your feed-intake endpoint — and because merchants retry failed webhooks, it also becomes a duplicate price row when the retry lands after your slow write partially succeeded.

Two-panel timeline comparing one price-change handled synchronously, where the caller waits through an email-provider brownout and receives a 502, against the asynchronous version, where the caller is released in 90 milliseconds and the work survives the same brownout by retrying in the backgroundPanel A — synchronous: the caller waits for everything0 s8.4 sparse feedmatch alerts 300msemail provider brownout — 8 s hang, then fail502 to merchantWhat the merchant sees: an 8.4-second hang, then an error — so it retries the whole webhook.What the user sees: no alert, and no record that one was ever owed.Panel B — asynchronous: the caller is released at the handoff0 s42 svalidatepublish + 202 (90ms)attempt 1 fails (brownout)backoff + jittersent ✓background consumer — nobody is waiting on this lane
Figure 1.1 — The same price change, two fates. Synchronously, the merchant's connection is held open through the email provider's brownout and ends in a 502; the alert is never sent and nothing records that it was owed. Asynchronously, the merchant is released in 90 ms with a 202, and the email survives the identical brownout because the retry happens on a lane where nobody is waiting. The work did not get faster — the caller got free.

Decoupling in three dimensions

A queue does not make anything faster. What it does is break the chain between producer and consumer along three separable axes, and being precise about which axis you need is the difference between a justified design and cargo cult.

Time. The consumer need not be running right now. If the notification service is redeploying, the price-change event waits in the broker and is processed on the other side of the deploy. Load. The queue absorbs arrival rates the consumer cannot match instantaneously; the backlog is the shock absorber. Failure. The producer's success no longer depends on the consumer's success — the merchant's webhook returns 202 whether or not the email provider is having a bad afternoon.

CouplingSymptom when it bitesWhat the queue buys
TimeDeploys and restarts of a downstream service cause caller errorsThe consumer can be absent; work waits instead of failing
LoadA 50× arrival spike exhausts connection pools or trips rate limitsArrival rate and processing rate are allowed to differ
FailureA third party's error rate shows up as your error rateProducer success is independent of consumer success

Load leveling deserves particular care, because it is the axis people most often misread. When a large merchant drops a nightly catalogue refresh, the wine-cellar pipeline sees roughly 50× its baseline publish rate for about four minutes. The queue swallows it. But the queue did not add capacity — it borrowed time. Every message in that spike still has to be processed by the same consumers at the same rate, and the backlog drains only because the arrival rate eventually falls below the processing rate.

Curve chart showing producer rate spiking far above steady consumer rate, with queue depth swelling during the spike and draining afterwards21:0021:0421:20rateproducer rate — 50× baseline for 4 minutesconsumer rate — steady, provisionedqueue depth — swells, then drainsspike endsbacklog clearedArea under the producer curve above the consumer line = work deferred, not work avoided.
Figure 1.2 — Load leveling borrows time, not capacity. The producer's spike is absorbed as queue depth, which drains only once arrivals fall back below the consumer's steady rate. The total work is identical in both regimes; what changed is when it happens and who waits for it. If your consumer's steady rate is genuinely below your average arrival rate, a queue does not save you — it just makes the failure quieter and slower.

The price: the caller no longer knows

Here is the whole trade in one line. Synchronously, the response means the work happened. Asynchronously, the response means the broker has the message. Those are different promises, and every remaining module of this course exists to buy back some named fraction of the difference.

The load-bearing idea

202 Accepted is a receipt for a handoff, not a receipt for an outcome. The instant you return it, the caller loses the ability to know whether the work succeeded, and you have taken on the obligation to make that knowledge recoverable some other way — through acknowledgments, dead-letter queues, gauges, and honest UI states.

It is worth reading the syllabus as a price list against that surrendered certainty. Acknowledgments buy back the broker has it durably (m2). At-least-once delivery buys back it will be processed (m3). Idempotent consumers buy back it will be processed once (m3). Dead-letter queues buy back failure will be visible (m5). Partition keys buy back related work happens in order (m6). Sagas buy back multi-step work ends in a coherent state (m7). Gauges and span links buy back I can see how far behind the truth I am (m8). No single mechanism restores the synchronous guarantee, and any vendor claiming otherwise is selling you the illusion rather than the machinery that produces it.

From your other life

You have priced this trade before. Filing a motion tells you the clerk has the document; it does not tell you the judge granted anything. Litigation practice is built on the gap between filing and outcome: docket monitoring, calendaring rules, follow-up obligations. Async engineering builds the same apparatus for the same reason — a receipt for a handoff creates a duty to track the outcome by other means.

When later is wrong

Asynchrony is a second system to operate, a lag to explain to users, and a substantially harder debugging story. Those costs are real and recurring, which means the case for a queue must be made per workflow rather than adopted as a house style.

The bright-line rule: if the caller needs the answer in order to decide what to do next, the work stays synchronous. Authorization decisions, validation of the caller's own input, price quotes shown before a purchase, and uniqueness checks that gate a form submission are all answers, not tasks. Queueing them means either inventing a polling protocol that reproduces request/response badly, or shipping a user experience that says “we'll tell you later whether your card was declined.”

In the wine-cellar pipeline, the split falls cleanly. Feed-credential validation is synchronous: the merchant's request is rejected in-band with a 401, because the merchant must know now whether to fix its credentials. Alert fan-out is asynchronous: nobody's next action depends on it, and its dependencies are exactly the slow, flaky, spiky ones described above.

When it misleads

“Queue it and poll for the result” is the most common way teams smuggle a synchronous requirement into an async system. It is not automatically wrong — long-running jobs with a status endpoint are a legitimate pattern — but if the user cannot proceed until the poll returns, you have paid every cost of asynchrony and kept every cost of waiting. Be honest about which one you built.

Module 02 Anatomy of a queue

A queue looks like a list you push to and pop from, and that mental model survives exactly until the first consumer crashes mid-work. What a broker actually provides is a small state machine per message plus a set of timers, and nearly every async bug you will debug is a disagreement between your assumptions and that state machine.

This module walks one real message — the canonical wine.price_changed event, printed here and reused for the rest of the course — through every state it can occupy, including the ones nobody diagrams on the whiteboard. By the end you will be able to say where a message is at any moment, what timer is running against it, and what happens if your consumer dies at that instant. The module ends on a deliberate cliffhanger: acknowledgment placement, which turns out to manufacture the delivery guarantee your whole system runs on.

The cast: producers, broker, consumers

Three components, three promises. The producer promises durable handoff: the publish call does not return success until the broker has committed the message. The broker promises durable custody: once accepted, the message survives broker restarts and is offered to a consumer until someone acknowledges it. The consumer promises processing and acknowledgment: it does the work, then tells the broker the message can be deleted.

Every queue feature you will ever configure is one of these three defending its promise against the others' failures. Publish confirms exist because producers cannot trust the network. Durability and replication exist because brokers crash. Visibility timeouts, receive counts, and dead-letter queues all exist because consumers crash, hang, or receive work they can never complete. Read a broker's configuration page with that frame and the options stop looking arbitrary.

Layered diagram of producer publishing into a broker queue holding four messages, one of them in flight with a visibility timeout clock, delivered to two competing consumers with an acknowledgment path drawn back to the brokerProducerfeed intake APIpublishBroker — queue: alertsevt_9f31 · in-flight ⏱ 30sevt_9f32 · availableevt_9f33 · availableevt_9f34 · availablereceiveConsumer Aprocessing evt_9f31Consumer Bidle — pollingack → deleteProducer promise: durable handoff. Broker promise: durable custody until acked. Consumer promise: process, then ack.Competing consumers: evt_9f31 is invisible to B while A holds it — that is what in-flight means.
Figure 2.1 — The three promises and where they meet. The producer hands off; the broker holds messages in order and marks one in-flight with a running visibility clock; competing consumers each take different messages, and the acknowledgment travels back to the broker as its own message. Nothing is deleted until that ack arrives — which is why a consumer that dies silently costs you latency rather than data.

The life of one message

Here is the canonical event this course uses everywhere. Every diagram, quiz, and worksheet from here forward refers to this exact payload.

{
  "event_id":      "evt_9f31",
  "type":          "wine.price_changed",
  "occurred_at":   "2026-03-14T09:12:04Z",
  "wine_id":       "wn_4417",
  "wine_name":     "Ch. Montrose 2016",
  "merchant_id":   "mch_082",
  "old_price_usd": 189.00,
  "new_price_usd": 154.00,
  "currency":      "USD"
}

Its life runs through five states, and this vocabulary is fixed for the rest of the course. Published: the producer's call has returned success and the broker has committed the message durably. Available: it is eligible for delivery and any polling consumer may receive it. In-flight: a consumer has received it, and the broker has hidden it from every other consumer for the length of the visibility timeout. Acked: the consumer confirmed completion and the broker deleted it. Two unhappy states complete the picture: redelivered — the message returned to available because the consumer nacked it or the visibility timeout expired — and dead-lettered, where a message that has been received too many times is moved off the main queue for triage.

State machine for one message: published to available to in-flight, then either acked and deleted, or returned to available as a redelivery when the visibility timeout expires or the consumer nacks, or moved to dead-lettered once the receive count is exceededpublishedavailablereceivein-flightvisibility timeout runningackackeddeletedredelivered — visibility timeout expired, consumer crashed, or nackthis transition is why module 3 exists: the work may already have happeneddead-letteredreceive count exceededreceives > maxFive states, six transitions. Every async bug in this course is a message somewhere on this diagram that you assumed was somewhere else.
Figure 2.2 — The message lifecycle. A message is published, becomes available, goes in-flight under a visibility timeout, and is deleted only on ack. The dashed red path back to available is redelivery — the broker's correctness mechanism and, simultaneously, the origin of every duplicate you will ever process, because the broker cannot know whether the crashed consumer had already done the work. Exceeding the receive limit routes the message to the dead-letter queue instead.

Visibility timeout and redelivery

In-flight means invisible, not gone. When a consumer receives evt_9f31, the broker starts a clock — the visibility timeout — during which no other consumer can see the message. If the ack arrives before the clock expires, the message is deleted. If it does not, the broker assumes the consumer died and returns the message to available.

That assumption is the broker's dead-man switch, and it is correct far more often than it is wrong. But notice what the broker actually observes: silence. It cannot distinguish a consumer that crashed from a consumer that is alive, healthy, and still working. A merely slow consumer is therefore the quietest source of duplicate processing in any queue system — no error is logged, no alarm fires, and two consumers do the same work in parallel while both believe they hold the message exclusively.

Timeline showing a consumer that legitimately takes 45 seconds while the visibility timeout expires at 30 seconds, causing the broker to redeliver the message to a second consumer, with both consumers completing and two alert emails being sentt=0st=30st=45st=76sConsumer Aprocessing evt_9f31 — 45 s, entirely healthyemail sent #1visibility timeout — 30 sexpires → broker assumes A is dead → redeliveredConsumer Breceives the same evt_9f31, processes itemail #2No error logged anywhere. Two emails, one price change, and both consumers report success.
Figure 2.3 — The slow consumer duplicates silently. Consumer A is healthy and takes 45 seconds; the 30-second visibility timeout expires at t=30 and the broker redelivers to Consumer B. Both send the alert. The remedies are to raise the timeout above p99 processing time, to extend the timeout by heartbeat while working, or to split the work into smaller messages — and, regardless of which you pick, to make the consumer idempotent, because no timeout value eliminates this race.
When it misleads

Setting the visibility timeout far above p99 processing time looks like a free fix, but it is a trade: the timeout is also your recovery latency after a genuine crash. A ten-minute timeout means a crashed consumer's message sits invisible for ten minutes before anyone can retry it. Tune it to p99 plus headroom, then use heartbeat extension for the genuinely long tail.

Acknowledgment is a contract

Everything above converges on one line of code: where you call ack. It is usually written without thought, and it decides the delivery guarantee your entire system runs on.

Consider the price-history consumer, which appends a row to the time series that powers the wine's price chart. Two orderings are possible. Ack first, then work: the message is deleted the moment it is received, and if the append fails — a transient database error, a pod eviction — nothing redelivers it. The chart has a silent gap that no alarm will ever mention, because from the broker's perspective the message was handled. Work first, then ack: the append happens, and only then is the message deleted. A crash between the append and the ack causes redelivery, and the row is appended twice.

The load-bearing idea

Ack placement does not merely affect reliability; it manufactures the guarantee. Ack-before-work yields at-most-once: crashes lose messages. Ack-after-work yields at-least-once: crashes duplicate them. There is no placement that yields neither, and the only indefensible position is not knowing which one your consumer is running.

Notice too that ack-after-work sets up a second question the code cannot dodge: what counts as “the work”? For the notification consumer, the side effect lives in a third-party email API you cannot roll back. For the price-history consumer, it is a row in your own Postgres, which you can wrap in a transaction. That difference determines how expensive deduplication will be — which is precisely where module 3 begins.

Module 03 Delivery guarantees, honestly stated

Module 2 ended on a single line of code — where the ack goes — and the claim that it manufactures your delivery guarantee. This module cashes that claim out. There are exactly two honest guarantees available to a consumer, they correspond to the two ack placements, and the industry's third option is a marketing category rather than a physical one.

The practical content is the machinery that makes the useful guarantee survivable: an idempotent consumer with a real dedup ledger, whose key, atomicity, and retention window are specified as precisely as Guide Nº 07 specified the producer-side idempotency key. By the end you will be able to state your system's guarantee in a sentence you could defend in an incident review, and to read a broker's exactly-once feature page and say precisely which hop it covers.

Two honest guarantees

At-most-once processing: the consumer acks before doing the work. Every message is processed zero or one times. Failures lose work, and they lose it silently, because from the broker's perspective nothing went wrong. At-least-once processing: the consumer acks after doing the work. Every message is processed one or more times. Failures duplicate work, visibly and in principle detectably.

These are not two settings on a dial with a better option in the middle. They are the two sides of one unavoidable window: between the side effect and the ack, something can crash, and you choose in advance whether that crash costs you a lost message or a repeated one.

Paired swimlane diagrams: on the left, a consumer that acks before working crashes and the message is lost; on the right, a consumer that works before acking crashes and the message is redelivered and processed twiceAck, then work → at-most-onceWork, then ack → at-least-oncereceiveack — deletedcrash before the work runsResult: no email, no message, no error.Loss is silent and unbounded.receivework: email sentcrash before the ackResult: redelivered, second email sent.Duplication is visible and suppressible.Pick which failure you can live with. There is no third lane — only a fourth mechanism (dedup) bolted onto the right one.
Figure 3.1 — Ack placement decides the guarantee. The same crash, in the same window, produces loss on the left and duplication on the right, purely because of where the acknowledgment sits. The window between side effect and ack cannot be closed; it can only be assigned a failure mode. Everything else in this module is about making the right-hand failure mode cheap.

Why at-least-once wins

Given a choice between losing work and repeating it, almost every production system should choose repetition, for one asymmetry: duplication is detectable and suppressible; silent loss is neither. A duplicate leaves evidence — two rows, two emails, two log lines with the same event id — and can be caught by a mechanism you control. A message lost between the ack and the crash leaves nothing behind. There is no query that returns the alerts you failed to send.

For the wine-cellar pipeline the choice is not close. A duplicate price-history row is a visible artifact you can dedupe at query time; a missing one is an invisible hole in a chart that a user might notice months later, if ever. A duplicate alert email is an annoyance; a missing alert is the feature not working, silently, for the user who cared most.

But choosing at-least-once means accepting an obligation. At the wine-cellar's volume — roughly 90,000 price events a day across the three consumers — a duplication rate of even 0.05% is about 135 duplicated processings per day. Deploys alone guarantee a cluster of them, because every rolling restart evicts consumers mid-work. Duplicates are not an edge case you hope against; they are a daily quantity you engineer for.

When it misleads

At-least-once is the right default, not a universal law. For a high-frequency counter — page views, ticks, sampled metrics — a duplicate silently corrupts an aggregate that nobody can later audit, while a dropped sample is noise inside an already-approximate number. When duplicates corrupt and losses are tolerable, at-most-once is the honest choice. Make it deliberately and write down why.

The idempotent consumer

Guide Nº 07 solved the producer's version of this problem: the client attaches an idempotency key, the server stores it transactionally with the side effect, and a resend replays the stored response instead of re-executing. The consumer's version is the same discipline pointed the other way, and it deserves the same precision — a dedup state whose location and lifetime are stated as numbers.

Cross-reference — Guide Nº 07, module 5

There you designed the mechanism that lets a client retry POST /cellar-entries safely: key scope per caller, key stored in the same transaction as the entry, 24-hour retention, defined behavior on same-key-different-body. Here you are building the mirror: the same four decisions, made by the consumer of a queue rather than the server of an API. Producer-side and consumer-side idempotency are one discipline with two vantage points, and a system that has only one of them is still duplicating work — just somewhere else.

The idempotent consumer pattern, concretely. The dedup identity is the event's event_idevt_9f31 — which the producer mints once per real-world occurrence and which survives every redelivery unchanged. The ledger is a table:

CREATE TABLE processed_messages (
  consumer_name  text        NOT NULL,
  event_id       text        NOT NULL,
  processed_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

Three decisions do the work, and each has a wrong answer that looks reasonable. Scope: the key is (consumer_name, event_id), not event_id alone — price-history and notification-delivery both legitimately process evt_9f31, and a shared ledger would let whichever ran first suppress the other. Atomicity: the insert must share a transaction with the side effect. Retention: seven days, chosen as arithmetic rather than instinct. In-queue redelivery is bounded at max receive count 5 × a 30-second visibility timeout — under three minutes. The long tail is dead-letter redrive: the DLQ retains for 14 days, but the triage runbook in m5 commits to deciding within five business days, so seven days covers every redrive this system performs as routine. Anything redriven later is reprocessed deliberately, by an operator who has been told so. A nightly job deletes older rows.

The consumer body is then unremarkable, which is the point:

def handle(msg):
    with db.transaction() as tx:
        try:
            tx.execute(
              "INSERT INTO processed_messages (consumer_name, event_id) VALUES (%s, %s)",
              ("price-history", msg.event_id))
        except UniqueViolation:
            return ack(msg)          # already processed — suppress, do not re-run
        tx.execute(
          "INSERT INTO price_history (wine_id, price_usd, observed_at) VALUES (%s,%s,%s)",
          (msg.wine_id, msg.new_price_usd, msg.occurred_at))
    ack(msg)
Swimlane sequence showing the broker redelivering event evt_9f31 to the notification consumer, whose insert into the processed-messages table violates the unique constraint inside the same transaction as the send record, so the second email is suppressed and the message is acknowledgedBrokernotification-deliveryPostgresdeliver evt_9f31 (attempt 1)insert dedup row + send record — commitemail #1 sent ✓crash before ackredeliver evt_9f31 (attempt 2)INSERT (notification-delivery, evt_9f31)unique violation — rollback, no sendemail #2 suppressedack — one email, two deliveries
Figure 3.2 — The duplicate suppressed, consumer side. The mirror of Guide Nº 07's Figure 5.1: there, the server recognized a resent request by its idempotency key; here, the consumer recognizes a redelivered message by its event_id, and the primary key on (consumer_name, event_id) makes the insert itself the deduplication check. Because the dedup row and the send record commit in one transaction, there is no window in which the email exists but the ledger does not.

Exactly-once, the honest version

Brokers advertise exactly-once. What they generally provide is deduplication across one specific hop under specific conditions — producer-to-broker deduplication within a session or window, or transactional read-process-write confined to the broker's own storage. Those features are real and useful. They are also scoped to territory the broker controls, and your side effects usually are not on that territory. No broker on earth can prevent a duplicate email from a third-party mail API, because it cannot see the API, cannot roll it back, and does not know the send happened.

So the end-to-end guarantee is assembled, not purchased. At-least-once delivery from the broker, plus deduplicated processing at the consumer, plus a dedup key that survives redelivery, produces a system in which each real-world effect happens once. That is exactly-once as an illusion — engineered, verifiable, and honest about its parts.

The load-bearing idea

The claim you can defend in an incident review is: at-least-once delivery, at-most-once processing per dedup key. Every word is checkable. “Delivery is at-least-once” is the broker's documented behavior; “at-most-once processing per dedup key” names the mechanism (the ledger), the identity (event_id), and the scope (consumer_name). “We have exactly-once” names nothing and can be falsified by a single duplicate email.

Reading the brochure

Feature page: “exactly-once semantics.” Translation questions, in order. Which hop — producer-to-broker, or broker-to-consumer, or both? Under what conditions — within a producer session, within a time window, only for transactional writes back into the same broker? What happens to my HTTP call to the email provider — answer: nothing, it is outside the boundary entirely. After those three questions you will usually find that the feature removes one source of duplicates and leaves yours untouched, which is worth knowing before you delete your dedup table.

Module 04 Who needs to know: topologies, events, and commands

Modules 2 and 3 covered one queue and one message. Real systems have several of each, and the questions become architectural: does this message go to one worker or to everyone, and what is it actually saying? Those two questions are the same design conversation, which is why they share a module.

The wine-cellar pipeline is the worked case throughout. One wine.price_changed event fans out to three independent consumers — alert-matching, notification-delivery, and price-history — and that fan-out is a topology choice with consequences for failure isolation, scaling, and how tightly your consumers are welded to your producer's internals. By the end you will be able to justify a topology from the phrase “who needs to know?”, classify any message as an event or a command by asking who owns the decision, and argue thin versus fat payloads for a specific field rather than in the abstract.

Queues split, topics copy

Two primitives, one difference. A point-to-point queue delivers each message to exactly one of N competing consumers: add workers and you process the same stream faster, because each message goes to whoever is free. A pub/sub topic copies each message to every subscriber: add a subscriber and a new party learns about everything, without the producer changing at all.

Competing consumers scale one job. Fan-out informs independent parties. The consumer group is the construct that lets one topic serve both shapes — each group receives every message, and within a group the messages are split among members — which is why modern brokers blur the vocabulary. The underlying question never blurs: who needs to know? If the answer is “exactly one worker, whichever is free,” you want a queue. If it is “three teams with different reasons,” you want a topic, and each subscriber should have its own queue behind it so one slow consumer cannot stall the others.

Two-panel comparison: on the left, one producer publishing to a point-to-point queue where a message goes to exactly one of three competing notification workers; on the right, the same producer publishing to a pub/sub topic that copies each message into three independent subscriber queues for alert matching, notification, and price historyPoint-to-point — competing consumersPub/sub — independent subscribersproducerqueue: send_alert_emailworker 1gets evt_9f31worker 2worker 3Each message is processed once, bywhichever worker is free. Add workersto go faster, not to add listeners.producertopic: wine.price_changedqueue Aqueue Bqueue Calert-matchingnotification-deliveryprice-historyEvery subscriber gets every message.Per-subscriber queues isolate failure:B backing up does not stall A or C.Same producer, same message, different guarantee: “processed once, by someone” vs. “delivered to everyone.”
Figure 4.1 — Split versus copy. On the left, three workers compete for one queue and each message is handled exactly once — the shape for scaling a single job. On the right, one topic copies each message into three subscriber-owned queues, so each consumer has its own backlog, its own retry budget, and its own failure. Attaching three different consumers to the single queue on the left is the classic fan-out bug: each would see about a third of the events and nobody would see all of them.

Events and commands

Two messages travel through the wine-cellar pipeline and they are grammatically different in a way that encodes an architectural difference. wine.price_changed is an event: past tense, a statement of fact. It asserts that something happened and takes no position on what anyone should do about it. send_alert_email is a command: imperative, an instruction. It asserts that a specific action should be performed by a specific handler.

The distinction is not stylistic. It determines who owns the decision. When you publish an event, consumers decide what it means: alert-matching decides that a price crossing a threshold warrants an alert; price-history decides it warrants a row. Adding a fourth interpretation later requires no change to the producer. When you send a command, the sender has already decided — it has determined that an email should be sent, to whom, and why — and the receiver's job is execution.

Both are legitimate; the failure is confusing them. A producer that publishes email_requested to a topic “so we stay event-driven” has issued a command in an event's costume. The name says a fact occurred; the content dictates an action; the decision that an email is warranted lives in the producer while appearing to live in the subscriber. Now nobody owns the alerting policy: the producer cannot change it without breaking subscribers who assume it, and the subscribers cannot change it because they did not make it.

EventCommand
GrammarPast tense fact — wine.price_changedImperative — send_alert_email
Decision ownerEach consumer, independentlyThe sender
Typical topologyPub/sub topic, fan-outPoint-to-point queue, one handler
Adding a consumerFree — the producer never learnsSuspicious — two handlers executing one order
Failure to handleThat consumer's problemThe sender's intent went unfulfilled
When it misleads

The reverse error is rarer but worse: dressing an event as a command to force ordering of business logic. If a producer emits update_price_history instead of wine.price_changed, it has taken on responsibility for knowing that a price history exists and how it should change — coupling itself permanently to a consumer's internals. When you are unsure, ask who would be blamed if the resulting action were wrong. That person's service should own the decision.

Thin events, fat events

Once you have chosen an event, one question remains: how much does it carry? A thin event carries identifiers and a reference — enough for the consumer to go fetch what it needs. A fat event carries the state the consumer will need, so no callback is required.

Neither is free, and the cost is a coupling in both cases; the choice is which coupling you can afford. Thin events couple consumers to your availability: every consumer must call back to your API at processing time, so your outage becomes their outage and your rate limits become their throughput ceiling — and because the fetch happens later than the event, they may read a state that has since changed, which quietly re-introduces the ordering problems of module 6. Fat events couple consumers to your schema: every field you ship becomes a field somebody might depend on, and refactoring an internal name becomes a breaking change you discover in someone else's incident channel.

Comparison of a thin event payload carrying only identifiers and a fetch URL against a fat event payload carrying full price context, with arrows showing that the thin event couples consumers to producer availability and the fat event couples them to producer schema stabilityThin{ "event_id": "evt_9f31", "type": "wine.price_changed", "wine_id": "wn_4417", "occurred_at": "...T09:12:04Z", "href": "/wines/wn_4417" }Couples to: producer API availabilityand to “now” — the fetch reads later stateFat{ "event_id": "evt_9f31", ... "wine_name": "Ch. Montrose 2016", "merchant_id": "mch_082", "old_price_usd": 189.00, "new_price_usd": 154.00 }Couples to: producer schema stabilityevery shipped field is a field someone depends onResolution: publish fat, but publish a declared contract — a documented subset marked stable, everything elseexplicitly not guaranteed. The coupling becomes a decision instead of an accident.
Figure 4.2 — Two payloads, two couplings. The thin event forces a callback that binds consumers to the producer's uptime and shows them the state at read time rather than event time; the fat event ships state and thereby binds consumers to every field it exposes. The practical resolution is to publish fat and declare which fields are contract, so consumers know what they may depend on and you know what you may rename.

For wine.price_changed the wine cellar publishes fat, and marks exactly four fields as contract: event_id, wine_id, new_price_usd, and occurred_at. The rest — wine_name, merchant_id, and the display fields that will inevitably accrue — are documented as convenience data subject to change. A consumer that reads wine_name for an email subject line is making an informed bet; a consumer that keys its logic on an undocumented internal reference is not, and now knows it.

Event sourcing, at concept level

Everything so far has treated events as messages in transit and current state as the source of truth. Event sourcing inverts that: the ordered log of events is the source of truth, and current state is a projection derived by replaying it. The wine's price is not a column you update; it is what you get when you fold every wine.price_changed event for wn_4417 in order.

What it buys is real. Perfect audit: the log is not a summary of what happened, it is what happened, and “who changed this price on March 3, and to what?” becomes a query rather than an archaeology project. Replay: a projection with a bug can be rebuilt by re-deriving it from the log, and a new projection — a question you did not know you had in 2024 — can be answered retroactively over history you never thought to record because you recorded everything.

From your other life

You already know why the docket beats the docket summary. An append-only event log is an audit trail with an enforcement mechanism: the record cannot drift from the facts because the record is the mechanism by which state is produced. In GRC terms, this collapses the usual gap between a control's operation and its evidence — you are not attesting that a log was kept alongside the work, because there is no work except the log. That is a strong property, and it is exactly the property most systems do not need badly enough to pay for.

Comparison of a mutable price row that answers only what the price is now against an append-only event log with a projection arrow producing the same row while also answering what the price was on a past date and who changed itState as truthwines: wn_4417 · price 154.00UPDATE overwrote 189.00 — goneAnswers: what is the price?Cannot answer: what was it on 3 Mar? who changed it? how often does this merchant reprice?Log as truth03-02 listed210.00 · mch_08203-03 changed189.00 · mch_08203-14 changed154.00 · mch_082foldprojection: price 154.00rebuildable at any timeAnswers all of the above, plus questions not yet asked — at the cost of projection code, schema evolution,and a query model nobody on the team has used before.
Figure 4.3 — State versus log. The mutable row answers one question well and destroys the evidence for every other. The append-only log answers the same question through a rebuildable projection while retaining history and attribution — which is why it is compelling for anything auditable, and why it is overkill for anything that is not.

The costs are equally real: projection code to write and operate, projections that must be rebuilt when their logic changes, event schema evolution over years of immutable history, and a query model your team has not used. The heuristic is narrow on purpose. Adopt event sourcing where audit and replay are product requirements — regulatory, financial, or contractual — not where they are aesthetic preferences. Price history in the wine cellar qualifies, and is in fact event-sourced by nature: the price-history consumer's append-only table is a projection of the event log. User preferences do not qualify, and event-sourcing them buys a change history nobody will ever read at the cost of machinery everyone will maintain.

Module 05 When messages fail

Modules 2 and 3 established that a message can be redelivered and that a good consumer can survive it. This module handles the case where redelivery does not help — because the dependency is down, or because the message itself can never succeed. Those two cases look identical at the moment the exception is raised, and treating them the same is the single most common failure-handling defect in async systems.

The frame is routing: every failed message must be routed somewhere, and there are exactly three destinations — back to the queue after a delay, into the dead-letter queue, or into the void. The third one is where most systems end up by accident. By the end of this module you will be able to classify a failure, size a retry policy that does not attack your own dependencies, and write the triage runbook that makes a dead-letter queue a control rather than a landfill.

Transient or permanent: the only question that matters

The email provider returns 503 Service Unavailable. The price payload arrives with new_price_usd missing. Both surface in your consumer as an exception, both increment the same error counter, and both will be retried identically by a naive handler. They could not be more different.

A transient failure is one where the same input may succeed later: a 503, a connection reset, a lock timeout, a rate limit. Patience is the correct response, because the world will change. A permanent failure is one where the same input will fail every time: a schema violation, a reference to a wine that does not exist, an amount that fails validation. Patience is worthless, because the world changing does not change the message.

So the first thing a consumer must do with an exception is classify it, and the classification should be explicit in the code rather than inferred from a retry count. In the wine-cellar consumers this is a small dispatch at the top of the error handler:

except ValidationError as e:         # permanent — the message can never parse
    dead_letter(msg, reason=str(e))
except (Timeout, ProviderUnavailable, RateLimited) as e:   # transient
    raise                            # let the broker redeliver after backoff
except Exception as e:               # unknown — treat as transient, but bounded
    log.exception("unclassified", event_id=msg.event_id)
    raise

The third branch matters. Unknown failures are treated as transient because the alternative — dead-lettering anything you did not anticipate — throws away recoverable work on the first unfamiliar exception. But they are bounded by the retry budget like everything else, so an unclassified failure that is in fact permanent still lands in the dead-letter queue rather than looping forever. The receive limit is the backstop for your incomplete classification, not a substitute for having one.

Backoff and jitter

Having decided a failure deserves patience, the question is how much. Retrying immediately, repeatedly, against a dependency that just failed is how you convert someone else's brownout into their outage — and, if they are a shared dependency, into yours.

Exponential backoff spaces attempts by a growing interval — 1 s, 2 s, 4 s, 8 s, 16 s — so a recovering dependency gets progressively more room. That solves the intensity problem for one client. It does not solve the coordination problem for many, and the coordination problem is the one that takes systems down.

Here is the mechanism, with wine-cellar numbers. The email provider browns out for 90 seconds. During the brownout, 200 notification workers each fail a send. All 200 failed at nearly the same instant, so all 200 retry at t+1 s, all 200 retry at t+2 s, and so on. Each retry wave arrives as a synchronized spike against a provider that is trying to recover. The provider's capacity comes back at 40% and is immediately consumed by a 200-request burst, which fails, which schedules the next synchronized burst. The recovery collapses repeatedly and the outage outlives its cause — a self-inflicted denial of service, launched by your own fleet, in perfect lockstep.

Jitter is the fix, and it is one line: instead of waiting exactly base × 2^n, wait a random duration in [0, base × 2^n]. The waves flatten into a spread, the recovering provider sees a smooth ramp instead of a wall, and the fleet stops attacking in formation.

Two aligned charts: without jitter, 200 consumers retry in synchronized spikes that exceed the recovering provider's capacity line at each interval; with full jitter, the same retries spread into a flat band that stays below capacityBackoff without jitter — 200 workers retry in lockstepprovider capacity while recoveringt+1st+2st+4st+8severy wave exceeds capacity → recovery collapses → next waveBackoff with full jitter — same 200 workers, same budgetwait ∈ [0, base × 2ⁿ] — load stays under capacity, provider recovers, work drains
Figure 5.1 — Jitter kills the herd. Exponential backoff alone controls how hard one client retries; it does nothing about the fact that a fleet fails together and therefore retries together. Randomizing each wait across the interval converts synchronized walls into a smooth band that a recovering dependency can absorb. Same retry budget, same total attempts, opposite outcome.
When it misleads

Backoff at the consumer is not the only lever, and past a certain error rate it is the wrong one. If the email provider is failing 100% of requests, backing off politely still means every message burns its full retry budget on a dependency you already know is down. A circuit breaker — stop calling entirely, fail fast, probe occasionally — preserves both the provider's recovery and your retry budgets. Backoff handles blips; breakers handle outages.

Poison messages and the dead-letter queue

A poison message fails deterministically on every attempt. In the wine cellar it looks like this — the canonical event, malformed by a feed adapter bug:

{
  "event_id":      "evt_9f31",
  "type":          "wine.price_changed",
  "occurred_at":   "2026-03-14T09:12:04Z",
  "wine_id":       "wn_4417",
  "wine_name":     "Ch. Montrose 2016",
  "merchant_id":   "mch_082",
  "old_price_usd": 189.00,
  "currency":      "USD "
}

It is valid JSON, so nothing rejects it at publish time. new_price_usd is absent, and currency carries a trailing space that fails the ISO-4217 check. Every consumer that receives it raises the same validation error, forever. Without a bound, this message is immortal: it is received, it fails, its visibility timeout expires, it becomes available, and it is received again — consuming throughput, filling logs, and in an ordered partition potentially blocking everything behind it.

The bound is the retry budget, usually expressed as a max receive count. When a message has been received more times than the limit, the broker moves it to a dead-letter queue instead of making it available again. That single move converts an infinite loop into a finite, inspectable artifact: the message is out of the hot path, its body and metadata are preserved, and its existence is now a number you can alarm on.

Flowchart of failure routing: a failed message is classified as transient or permanent; transient failures back off with jitter and retry while budget remains, then dead-letter; permanent failures dead-letter immediately; the dead-letter queue raises an alarm leading to triage, then either redrive after a fix or discard with a recordprocessing failsclassify:transient or permanent?transientbackoff + jitterwait ∈ [0, base × 2ⁿ]budget remaining?yes → retryno — exhaustedpermanent — poison message,never retrieddead-letter queuealarm: age-of-oldest > 15mtriage — named ownerfix producer → redrivediscard — with a recordredrive is safeonly because theconsumer isidempotent (m3)
Figure 5.2 — Failure is a routing decision. Classification comes first: permanent failures skip retries entirely and dead-letter on the first attempt, while transient failures back off with jitter until the retry budget is exhausted. The dead-letter queue is not the end of the path — the alarm, the named owner, and the redrive-after-fix are what make it a control. The redrive edge is safe only because the consumer deduplicates, which is why m3 precedes this module.

Who watches the DLQ

A dead-letter queue with no alarm and no owner is not a safety net. It is a place where data goes to expire quietly, usually after 14 days, with better branding than /dev/null. The mechanism is only worth what its triage process is worth, so here is the full narrative for the malformed price event above.

Detect. The alarm is not on DLQ depth — depth can sit at zero for weeks and then jump to one, which is exactly the case you must catch. It is on age-of-oldest-message on the DLQ exceeding 15 minutes, paired with a low-threshold depth alarm at >= 1 for a five-minute window. Any dead letter is an event; a dead letter nobody has looked at in 15 minutes is a page.

Diagnose. The operator pulls the message without consuming it, and reads three things: the body, the failure reason recorded at dead-letter time, and the receive count. Here the body is missing new_price_usd and the reason is ValidationError: new_price_usd required; currency 'USD ' not ISO-4217. Receive count is 1 — it was classified permanent and dead-lettered immediately, which itself tells the operator this was never a provider blip. A query for sibling messages shows 340 more from mch_082 in the same 20-minute window, which converts an incident about one message into an incident about a feed adapter release.

Decide. Three options, and the decision belongs to the price-pipeline on-call, not to whoever noticed. Patch the producer and redrive — correct here, because the events represent real price changes users are owed. Discard with a record — correct when the messages are duplicates of data since superseded. Quarantine — correct when the decision needs a person who is not available at 3 a.m.

Redrive, after the fix. The order is not negotiable: the feed adapter is fixed and deployed first, and only then are the 341 messages moved back to the main queue. Redriving first means 341 messages take another lap and return to the DLQ, and if any of them had partially processed before failing, it means partial work repeated. That the redrive is safe at all is entirely due to the processed_messages ledger from m3 — the price-history consumer that already appended a row for a partially-processed event will suppress the second append on the redrive.

From your other life

An unmonitored dead-letter queue is a detective control that detects nothing. It has a control description, an owner on paper, and a retention period — and it produces no signal any human ever sees, which is the same finding as an exception report that is generated monthly and reviewed never. The audit question is not “do you have a DLQ?” but “show me the last three items in it and what was decided.” Design the runbook so that question has an answer.

The record of discard

When the decision is to discard, write the discard down: event id, body hash, reason, decider, timestamp. You can discard data; you cannot discard the record that you discarded it. This costs one insert into a dlq_dispositions table and is the difference between “we investigated and decided these 60 events were superseded” and “we don't know what was in there.”

Module 06 Ordering and partitioning

Everything so far has treated messages as independent. Many are not: two price changes for the same wine have a correct order, and processing them backwards leaves the wrong price in the chart. The instinct is to ask the broker for ordering, and the broker will offer it — but only within a partition, which is a deliberate limitation rather than a missing feature.

This module explains why total ordering is the most expensive guarantee in messaging and the least often needed, how partition keys let you buy order exactly where it matters, and what happens when your key concentrates traffic on one shard. It is the shortest module in the course and the one whose mistakes are the hardest to see, because a cross-partition ordering bug produces correct-looking data that is quietly wrong.

Why global order doesn't scale

Total order means: if message A was published before message B, every consumer observes A before B. To provide that, some component must decide the single global sequence — and every message must pass through it. That component is a serialization point, and its throughput is the system's throughput. You may run a hundred consumers; you will still be limited to what one node can sequence.

Worse, that ceiling comes with a coupling. A single global order means a single stream, which means a slow message blocks every message behind it regardless of whether they are related. In the wine cellar, a globally ordered price stream would let one wine's slow-processing event delay alerts for every other wine in the catalogue — head-of-line blocking, applied to work that had no reason to wait.

Brokers therefore offer a different guarantee: order within a partition. A topic is divided into partitions, each an independent ordered log, each with its own consumer. Order is preserved inside each partition and explicitly not preserved across them. Total throughput scales with partition count, and the cost is that you must decide which messages need to share an order.

The partition key: order where it matters

Nobody needs the Château Montrose price change ordered against the Barolo's. They are unrelated facts about unrelated objects, and observing them in either order harms nothing. What must be ordered is each wine's own history: if wn_4417 goes 189.00 → 154.00 → 161.00, a consumer that observes those out of order will leave the chart, and any derived current price, simply wrong.

That is what a partition key expresses. The key names the entity whose history must stay coherent; all messages sharing a key are routed to the same partition and therefore observed in publication order. For wine.price_changed the key is wine_id, and the guarantee purchased is precise: all events for one wine are observed in order; no ordering is guaranteed between different wines.

Choosing the key is therefore an act of naming the ordering you actually need. Ask what would be wrong if two messages arrived swapped. If the answer is “nothing,” they do not need to share a key. If the answer is “the derived state would be incorrect,” they do — and the key is whatever they have in common.

Grid diagram of producers hashing on wine id into four ordered partitions each with its own consumer, where partition two carries a much deeper backlog and its consumer is annotated as lagging while the other three idleproducershash(wine_id) % 4partition 0depth 2partition 1 — hotdepth 84,000partition 2depth 1partition 3depth 3consumer — idleconsumer — 40m lagconsumer — idleconsumer — idleOrder is preserved down each lane and nowhere across them. Adding consumers does not help partition 1:a partition has exactly one consumer within a group, because that is what makes its order meaningful.
Figure 6.1 — Partitions are independent ordered lanes. The key routes each message to a lane, order holds within a lane, and each lane is consumed by exactly one member of the consumer group. That last constraint is why a hot partition cannot be scaled out of: adding consumers leaves the overloaded lane with the same single reader, so the remedy has to be a different key rather than more capacity.
When it misleads

The trap is causally related events with different keys. wine.price_changed is keyed on wine_id; if wine.delisted is keyed on merchant_id, the two land in different partitions and may be observed in either order — so a consumer can process a price change for a wine it has already seen delisted, or worse, alert on a wine that was delisted milliseconds earlier. Either unify the keys so causally related events share a partition, or write consumers that check current state rather than trusting arrival order. Choosing neither is choosing the bug.

Timeline showing a price-changed event keyed on wine and a delisted event keyed on merchant, published five milliseconds apart into different partitions and consumed in the opposite order, causing an alert email for a wine that is no longer for salePublishedwine.delisted 09:12:04.000key merchant_id → partition 3wine.price_changed 09:12:04.005key wine_id → partition 1Observed by alert-matchingwine.price_changed first→ emails 412 watchers: “now $154!”wine.delisted second→ too late, mail is sentOrder is a per-key promise. Events that must be mutually ordered must share a key — or the consumer mustre-read current state before acting instead of trusting arrival order.
Figure 6.2 — The cross-partition illusion. Two causally related events published 5 ms apart with different keys land in different partitions, and nothing in the system promises a relationship between their arrival times. The result is a user-visible defect — 412 people invited to buy a wine that was delisted first — produced by data that is individually correct in every message.

Hot partitions

Partitioning distributes load only as evenly as the key distributes traffic. When one key carries a disproportionate share, its partition becomes a hot partition: a single lane with a growing backlog and a single consumer that cannot be scaled out, because a partition has exactly one reader within a consumer group.

The symptom is distinctive and easy to misread from aggregate dashboards. Total queue depth looks moderate. Average consumer lag looks fine. One consumer sits at 40 minutes of lag while the other three are idle. Any metric that averages across partitions hides this completely, which is why per-partition lag is the gauge that matters — a point module 8 returns to.

The remedies, in order of preference. Choose a better key. If merchant_id is skewed because one merchant is 45% of your volume, wine_id may distribute far better while still buying the order you actually need. Increase partition count. This helps only if the skew is a collision artifact rather than a genuinely dominant key; if one key is hot, more partitions leave it exactly as hot. Salt the key. Route wn_4417 to wn_4417#1 through wn_4417#4, spreading one entity across four partitions.

What salting spends

Salting distributes a hot key by destroying the ordering guarantee you partitioned to obtain. After salting, wn_4417's own events are spread across four independent lanes and may be observed in any order. That is acceptable for the price-history consumer, which appends rows carrying occurred_at and sorts at read time. It is unacceptable for any consumer that folds events into current state, because “the latest price” now depends on which lane happened to be read last. Salt only where the consumer's logic is order-independent, and know that you are spending the guarantee, not optimizing it.

Module 07 Living with eventual consistency

Once derived state is updated by consumers, every read is a read of the past by some margin. That margin is not a defect to be eliminated; it is a property to be measured, bounded, and — this is the part teams skip — shown honestly to the people affected by it.

This module has two halves. The first is about reads: how far behind your copies are, why users notice their own writes above all others, and the UI patterns that tell the truth about lag rather than papering over it. The second is about writes that span services: why the distributed transaction you want is usually unavailable, what a saga replaces it with, and why a compensating action is rescission rather than time travel — a distinction you have spent a career pricing in another vocabulary.

The lag between truth and its copies

The wine cellar has one source of truth for a price — the wines row written at feed intake — and several derived copies: the price-history series, the search index, the analytics rollup, and each user's alert state. Every copy is updated by a consumer, and each consumer has its own lag.

“Eventually consistent” therefore is not one property of the system. It is a set of windows, one per consumer, each measurable and each different by orders of magnitude:

Timeline showing a write landing at the source of truth at time zero, with the price-history projection catching up at 0.4 seconds, the search index at 2 seconds, and the analytics rollup at 90 seconds, and a user read at 1 second annotated as fresh on some surfaces and stale on otherst=00.4s2s90ssource of truth — t=0price-history · 0.4ssearch index · 2sanalytics rollup · 90s (batched)user reads at t=1schart: fresh · search: stale · analytics: stale“Eventually” is a set of numbers, one per consumer — not one vague apology.
Figure 7.1 — The consistency window, measured. One write, four surfaces, four different catch-up times spanning two orders of magnitude. A read at t=1 s sees a fresh chart and a stale search result, and both are correct behavior. The engineering questions are “how far behind, and behind what?” — each answerable with a number from consumer lag — rather than “how do we make it instant?”, which has no affordable answer.

Two consequences follow. First, every consistency conversation should name a surface: “the search index is 2 seconds behind at p99” is a fact you can alarm on, while “the system is eventually consistent” is a shrug. Second, the windows are already instrumented — consumer lag from module 8 is the consistency window, expressed in the same seconds. You are not adding new observability; you are reading existing observability as a product property.

Read-your-writes and honest UIs

Users tolerate a two-second-stale search result and do not tolerate their own action vanishing. The lag that generates support tickets is almost always read-your-writes lag: someone creates an alert, the list page is served from a projection that has not caught up, the alert is not there, and the user concludes the product lost their data. Nothing is broken; the report is nonetheless correct as a description of the experience.

The fixes are targeted, and the targeting is the point. You do not need global strong consistency to solve this; you need the author to see their own write. Three patterns, in increasing cost:

Read the author's own writes from the source of truth. The alert list, when requested by the user who just created an alert, is served from the primary rather than the projection — for that user, for a bounded window after the write. Everyone else keeps the cheap read.

Render the enqueued action as an explicit pending state. When the work genuinely has not happened, say so: “Alert created — we'll check prices within a minute.” This is not a consolation prize. It is more informative than a checkmark, because it tells the user both what is true now and what to expect next.

Include the write in the response. The API that accepted the creation already knows the resulting object; returning it lets the client render the new state without any read at all.

The load-bearing idea

Never fake completion. A UI that shows a checkmark for work that is enqueued has converted an engineering property — lag — into a trust incident, because the first time the work fails, the user has been told it succeeded. “Scheduled,” “processing,” and “sent at 09:14” are three different truths and deserve three different pieces of UI.

The wine cellar's alert page states, verbatim: “Alert active. We check prices continuously; alerts arrive within 5 minutes of a price change.” That sentence is a promise with a number in it, and it is the same number the m8 worksheet derives alarm thresholds from. Consistency claims in the interface and alarm thresholds in the runbook should be the same figure — otherwise one of them is fiction.

Sagas: multi-step without the global transaction

Reads were the easy half. The hard half is a business operation spanning several services, each with its own database: reserve a bottle, charge the card, notify the buyer. You want all three or none. What you want is a transaction, and across service boundaries you cannot have one at acceptable cost.

The mechanism usually proposed is two-phase commit: a coordinator asks every participant to prepare, then tells everyone to commit. It works, and it couples the availability of every participant to the availability of every other, plus the coordinator. Participants hold locks across the whole protocol — including across a payment provider's 30-second timeout — so a slow third party becomes a bottle-inventory lock held for 30 seconds, blocking every other buyer of that wine. And if the coordinator dies after prepare, participants sit locked and undecided until it returns.

A saga replaces this with a sequence of local transactions, each committed immediately, each paired with a compensating action that semantically unwinds it if a later step fails. Nothing is held. Intermediate states are visible — a reservation exists briefly for a purchase that will not complete — and that visibility is the price of staying available.

Two-panel swimlane comparison of the same reserve, charge, and notify workflow: on the left a distributed transaction holds an inventory lock across a payment timeout blocking all participants, on the right a saga commits the reservation locally, fails the charge, and runs a compensating release while the irreversible notification step never runsDistributed transactionSaga with compensationscoordinatorinventorypaymentsprepareprepareLOCK30 stimeoutEvery other buyer of this wineis blocked for 30 seconds by athird party's slowness. Coordinatordeath leaves both locked, undecided.orchestratorinventorypaymentsreserve bottlecommitted locally ✓charge carddeclined ✕compensate: releasereservation released —rescission, not rewindnotify buyer — never runs (sequenced last)Nothing was locked. Another buyer could have taken the bottlethe moment it was released. That visibility is the price of availability.
Figure 7.2 — Saga versus the doomed transaction. On the left, prepare-and-hold binds inventory availability to a payment provider's latency and to a coordinator's liveness. On the right, each step commits locally and the failed charge triggers a compensating release; the irreversible notification is sequenced last and therefore never needs unwinding. The saga's cost is a briefly visible reservation for a purchase that did not complete — a real, explainable intermediate state rather than a system-wide stall.

Compensation is a remedy, not time travel

The word “compensating” does real work and is routinely misread as “undoing.” A compensating action is a new forward action that produces a semantically acceptable outcome. It does not rewind the world, and designing sagas as if it did produces compensations that are fiction.

Sort every step into three categories before writing a line of code. Reversible: the compensation restores the prior state cleanly — release a reservation, and inventory is as it was. Compensable with residue: the compensation is adequate but leaves a trace — a refund reverses a charge, but the customer's bank shows a pending charge for a day, and both entries appear on the statement. Irreversible: no compensation exists — an email in someone's inbox cannot be unsent, and “delete the notification row” compensates your database rather than the world.

The design rule follows directly: sequence irreversible steps last. If notify runs after charge, a failed charge means the notification never ran and needs no compensation. If notify runs first, every downstream failure requires a correction email, which is a worse product experience and a permanent one.

From your other life

This is remedies doctrine wearing an engineering costume. You rarely get the transaction unwound as though it never occurred; you get rescission where restoration is feasible, restitution where it is partial, and damages or a corrective disclosure where it is not. The classification above is exactly the one you have run for eight years: can this be put back, can it be made whole, or can it only be answered? Saga design that skips this step produces the engineering equivalent of pleading specific performance against a party who no longer holds the thing.

The cellar-purchase saga, classified

Reserve bottle — reversible; compensation: release the reservation, no residue beyond a brief window where another buyer could have taken it. Charge card — compensable with residue; compensation: refund, residue: a pending charge visible to the buyer's bank for up to one business day and two line items on the statement, which the support macro names explicitly rather than denying. Notify buyer — irreversible; no compensation exists, therefore sequenced last, after both prior steps have committed. If it fails, the saga is complete and the failure is a delivery problem for m5's retry path, not a reason to unwind a completed purchase.

Module 08 Running it: jobs, gauges, and the field guide

Seven modules of design; one of operation. Async systems fail differently from synchronous ones, and the difference is that they fail quietly — nothing 500s, no user sees an error page, and the first signal is a support ticket about an email that never came. Making that failure loud is the work of this module.

Three subjects. Scheduled jobs, which answer “at this time” where queues answer “when this happens,” and whose classic wounds — overlap, missed runs, timezones — are old and still shipping. The three gauges that make a queue observable, each catching failures the others miss. And tracing across the async boundary, so a four-minute consumer lag stops being attributed to an API that responded in 80 ms. The module closes the course with a field guide to every anti-pattern in it: name, symptom, corrective, and the module that teaches the fix.

The humble background job

A queue answers “when this happens.” A scheduled job answers “at this time.” The distinction sounds trivial and decides architectures.

Jobs suit periodic sweeps over state: reprice every cellar nightly, expire stale reservations, send the weekly digest, delete records past retention. The work is defined by a clock and a query, not by an event, and would still need doing if nothing had happened. Queues suit reactions: something occurred, and work follows from it.

Forcing either into the other's shape produces the ugliest systems in this guide. A job that polls a table for “things that changed” is a queue with worse latency, no delivery guarantee, and hand-rolled redelivery — sometimes a fine trade, as module 2's SKIP LOCKED quiz allowed, but a trade you must make knowingly. A queue used for periodic work means scheduling by publishing a message to yourself with a delay, which reinvents cron with fewer guarantees and no visibility into whether the schedule is still running.

The wine cellar's secondary example is a genuine job: the nightly cellar-valuation run at 02:00 UTC, which reprices every user's cellar against the latest prices and writes a valuation row per cellar per day. It is a sweep over all state, driven by a clock, and it would run identically whether or not any price changed that day.

Cron's sharp edges

Three wounds, all old, all still shipping.

Overlap. The valuation job normally runs 02:00–02:50. One night the catalogue grew and it runs 70 minutes; at 02:00 the next invocation starts while the previous is still writing. Two instances now sweep the same cellars and write the same valuations rows, double-counting anything they both touch. The fix is a distributed lock with a TTL — the second instance tries to acquire, finds it held, and follows a policy. The policy is the part teams omit: skip (log and exit, correct for idempotent nightly sweeps where tomorrow's run will catch up) or queue (wait and run after, correct when every run must happen). Choosing at 3 a.m. during an incident is choosing badly.

Missed runs. A deploy freeze eats the 02:00 slot; the job simply does not run. Cron has no memory and will not tell you. You need an explicit catch-up policy decided in advance — run it manually against a backfill window, or skip the day — plus a heartbeat alarm that fires when the job has not completed in over 26 hours. A job whose absence produces no signal is not scheduled; it is optional.

Timezones. Scheduling at 02:30 local time means that one night a year 02:30 does not exist and another night it happens twice — so the job either silently skips or runs twice, and both are DST doing exactly what it says on the tin. Schedule in UTC. If a job must align to local business hours, compute the offset explicitly and write down which side of the DST boundary you chose.

Timeline showing a nightly valuation job that runs long and overlaps with the next invocation, both writing the same table during a shaded overlap window, and below, the same night with a distributed lock where the second instance finds the lock held and skipsTuesday, without a lock02:0003:0003:40run A — 02:00 to 03:10 (70 min, catalogue grew)run B — starts 03:00 on schedule10-minute overlap — both writing valuations rows for the same cellars→ double-counted totals, no error anywhereSame night, with a lock and a skip policyrun A holds lock valuation:2026-03-14 (TTL 3h)run B: lock held → skip, log, alertA finishes,releases lock
Figure 8.1 — Overlap, and the lock that prevents it. A job that runs longer than its interval will eventually meet itself, and the resulting double write produces plausible-looking wrong numbers rather than an error. A distributed lock with a TTL longer than the expected runtime turns the collision into a decision — and the decision (skip or queue) must be made in advance and logged loudly, because a silently skipped run is its own failure mode.

The three gauges

A queue is invisible by construction: no user is waiting on it, so nothing complains. Three gauges make it visible, and the reason to run all three is that each detects failures the others miss.

Queue depth — how many messages are waiting. Detects a consumer that has stopped or fallen behind arrival rate. Blind to a small number of messages that are stuck, and blind to skew when it is aggregated across partitions.

Consumer lag — how far the consumer trails the newest message, in messages or in time. Detects falling behind, and per-partition lag detects the hot partition from module 6 that aggregate metrics hide. Blind to a queue that is empty because the producer has stopped.

Age-of-oldest-message — how long the unluckiest message has been waiting. This is the gauge that catches what the others cannot: a single poison message cycling through receive-fail-redeliver keeps depth near zero and lag near zero while its age climbs without bound. Depth says one; lag says fine; age says forty minutes and rising.

Three aligned charts showing how the same three gauges move differently for a consumer crash, a hot partition, and a poison message, so the failure shape can be identified from which gauges riseConsumer crashHot partitionPoison messageAll three climb together.Unambiguous: nothing isconsuming.Aggregate depth and lag lookhealthy; age climbs. Per-partitionlag is the gauge that names it.Depth ≈ 1, lag ≈ 0, age climbingalone. Only age-of-oldest sees it.queue depthconsumer lagage-of-oldest-messageRead the three together and the failure names itself. Read only depth and two of these three are invisible.
Figure 8.2 — Three gauges, three signatures. A crash lifts all three; a hot partition lifts age while aggregate depth and lag stay flat; a poison message lifts age alone with depth pinned near one. The diagnostic value is in the combination — which is why alarming only on depth, the most intuitive gauge, leaves the two failure modes this course spent modules 5 and 6 on completely unmonitored.

Alarm on trends and ages, not only on absolute depth. Depth's correct threshold depends on drain time: depth ÷ consumer rate gives the minutes until the queue is clear, and that number — not the raw count — is what the user-facing promise constrains.

Tracing across the async boundary

A trace that ends at publish makes your API look guilty for your consumer's sins. The support ticket says “the alerts feature is slow”; the trace shows a request that completed in 80 ms; the investigation stalls, because the four minutes the user actually experienced happened in a process the trace never entered.

The async-specific move is small: trace context rides inside the message. The producer injects the current trace and span identifiers into the message's attributes — not its body, since the payload is a business contract and this is transport metadata — and the consumer extracts them when it receives the message and starts its span with a link back to the producer's.

A link, not a parent-child edge, and the distinction is load-bearing. Async parent-child spans are actually permitted — a child may begin after its parent has ended — but a link is the better fit here for two reasons. First, the producer does not await the consumer: the producer span ended at publish, and the consumer span begins whenever it begins — 40 seconds later, or 40 minutes later after a redrive — so the link says “this work descends from that request” without pretending the request stayed open waiting for it. Second, it handles fan-out correctly: one publish, three consumer spans, three links, no fiction about a single call tree.

Cross-reference — Guide Nº 09

Span mechanics, sampling decisions, and how to make traces queryable are covered there; this section takes only the async-specific move. Two things carry over that are easy to get wrong here: the sampling decision must be propagated with the context, or a sampled request will produce an unsampled consumer span and a permanently half-missing trace; and the consumer must record the message's occurred_at as an attribute so end-to-end latency — publish to processed — becomes a queryable number rather than an inference.

Swimlane diagram where an API request span ends at publish, trace context rides inside the message attributes through the broker, and the consumer span starts forty seconds later linked back to the producer span, with a user-visible latency bracket spanning bothAPIspan: POST /prices 80msends at publish evt_9f31Brokerattributes: traceparent=00-4bf9…-a3c1…-01context rides with the message — including the sampling flagConsumerspan: notification-delivery 220msspan link — not parent-child: the API request did not last 40 secondswhat the user experienced: 40.3 s from price change to email
Figure 8.3 — The trace crosses the boundary. Context travels in the message attributes; the consumer starts a new span linked to the producer's rather than nested inside it, because the API request genuinely ended at 80 ms. The link is what turns “the alerts feature is slow” from an argument into a query — and the bracket beneath, publish-to-processed, is the only latency number the user would recognize as their experience.

The anti-pattern field guide

The closing sweep. Each entry: the name, the symptom you would actually observe, the corrective, and where it was taught. This is the page to reread before a design review.

Anti-patternSymptom in the wildCorrective
Database as a queue, unexamined (m2)A status column polled on an interval; no visibility timeout, no receive count, no dead-letter path; a crashed worker leaves rows stuck in processing forever.Not automatically wrong — SKIP LOCKED at low volume is legitimate. Price it: implement a lease column with expiry, an attempt counter, and a failure table, or move to a broker that has them.
Brochure exactly-once (m3)Design docs cite the broker's exactly-once feature; the dedup table was never built or was deleted; duplicates appear in an external system the broker cannot see.State the guarantee as “at-least-once delivery, at-most-once processing per dedup key,” and name which hop the broker's feature actually covers.
The non-idempotent consumer (m3)Duplicate side effects clustered around deploys and at peak load; error rates normal; the broker is blamed.A processed-messages ledger keyed on event_id, scoped per consumer, inserted in the same transaction as the side effect, with a numeric retention window.
Payload coupling to producer internals (m4)A consumer breaks when the producer renames an internal field; the producer team is asked to stop refactoring.Publish a declared contract — the fields consumers may depend on — with everything else marked convenience data, and version the event when a contract field must change.
Retry storm without jitter (m5)A dependency's brief outage keeps collapsing on recovery; your fleet's request graph shows synchronized spikes at 1 s, 2 s, 4 s.Exponential backoff with full jitter; a circuit breaker for outages rather than blips.
No DLQ, or a DLQ nobody watches (m5)No alarm on the dead-letter queue; nobody can name its owner; messages silently expire at retention.Age-of-oldest alarm plus a depth alarm at one; a named on-call owner; a triage runbook; a durable record of every discard.
Relying on cross-partition ordering (m6)Rare, unreproducible bugs where two related events were processed in the wrong order — an alert for a delisted wine, a state fold that lands on the older value.Unify the partition key for causally related events, or have consumers re-read current state instead of trusting arrival order. Preferably both.
Hiding eventual consistency from users (m7)A checkmark shown for enqueued work; tickets filed as “data loss” when a user's own write does not appear; support cannot explain the lag because the UI denies it exists.Read-your-writes for authors, explicit pending states with a stated expectation, and a labeled freshness window that matches the number in your alarms.
Fake compensation in a saga (m7)A compensating action that deletes your row while the world outside — a sent email, a captured charge — is unchanged.Classify every step as reversible, compensable-with-residue, or irreversible; name each residue; sequence irreversible steps last.
The monolithic do-everything consumer (m8)One handler does matching, notification, and history in a single transaction; the email provider's outage stalls price-history writes; a retry re-runs all three; you cannot scale the slow part alone.One consumer per concern behind its own queue — the fan-out this course has used since m4 — so each has independent retries, independent scaling, and independent failure.
Cron without a lock or a catch-up policy (m8)Plausible but wrong aggregate numbers after a long night; a missed run nobody noticed for a week; a job that skipped or doubled on a DST boundary.Distributed lock with TTL plus a pre-decided skip-or-queue policy; a heartbeat alarm on time since last successful completion; schedules in UTC.
The load-bearing idea

Read the table again and notice what every corrective has in common: it names a mechanism, a number, and an owner. That is the whole course. Asynchrony surrenders the caller's certainty, and you buy fractions of it back by specifying — not by hoping — which guarantee each mechanism provides, what its window is, and who is paged when it stops holding.

Concept index

Asynchrony
Decoupling the request from the work so the caller is freed before the work completes.
Load leveling
Letting a queue absorb a traffic spike so consumers process at their own steady rate; borrows time, not capacity.
Producer
The component that publishes messages to a broker and whose promise is durable handoff.
Consumer
The component that receives, processes, and acknowledges messages.
Broker
The intermediary that stores messages durably between producer and consumer.
Acknowledgment (ack)
The consumer's signal that a message is fully processed and may be deleted; its placement relative to the work decides the delivery guarantee.
Visibility timeout
The window during which an in-flight message is hidden from other consumers; expiry triggers redelivery.
Redelivery
The broker re-offering a message after a crash, nack, or expired visibility timeout — the built-in source of duplicates.
At-most-once
Delivery guarantee where a message is processed once or not at all; failure loses work silently.
At-least-once
Delivery guarantee where every message is processed, possibly more than once; failure duplicates work visibly.
Exactly-once (end-to-end)
The client-visible illusion assembled from at-least-once delivery plus deduplicated processing; not a broker feature you can buy.
Idempotent consumer
A consumer that detects and suppresses reprocessing of messages it has already handled.
Processed-messages table
The dedup ledger whose insert shares a transaction with the consumer's side effect.
Point-to-point queue
Topology where each message is delivered to exactly one of N competing consumers.
Pub/sub topic
Topology where each message is copied to every independent subscriber.
Consumer group
A set of consumers sharing one subscription so a topic's messages are split among them like a queue.
Fan-out
One published message triggering multiple independent downstream consumers.
Event
A message stating a fact that happened; consumers own the decision about what it means.
Command
A message ordering specific work; the sender owns the decision.
Thin event
An event carrying identifiers and a reference; consumers fetch details, coupling them to the producer's availability.
Fat event
An event carrying the full payload, coupling consumers to the producer's schema stability.
Event sourcing
Storing the event log as the source of truth and deriving current state as a projection.
Exponential backoff
Retry spacing that doubles the wait after each failure so a recovering dependency isn't re-crushed.
Jitter
Randomization added to retry timing to de-synchronize many clients and prevent retry storms.
Retry budget
The bounded number of attempts (or time window) a message gets before failure handling escalates.
Poison message
A message that fails deterministically on every attempt and can never succeed.
Dead-letter queue (DLQ)
The destination for messages that exhaust their retry budget; a holding cell for triage, not a resolution.
Redrive
Returning dead-lettered messages to the main queue after the underlying cause is fixed; safe only with idempotent consumers.
Partition
An ordered shard of a topic; ordering is guaranteed within a partition, not across them.
Partition key
The attribute that routes a message to a partition, defining the entity whose events stay ordered.
Hot partition
A partition receiving disproportionate traffic because of a skewed key, lagging while siblings idle.
Eventual consistency
The property that derived state converges to the source of truth after a measurable lag.
Read-your-writes
The consistency pattern guaranteeing an author sees their own write immediately, however stale other reads may be.
Saga
A multi-step workflow built from local transactions, each paired with a compensating action, replacing a distributed transaction.
Compensating action
The step that semantically unwinds a completed saga step — rescission, not time travel.
Consumer lag
How far a consumer trails the newest message; one of the three async gauges.
Age-of-oldest-message
How long the longest-waiting message has been in the queue; catches poison and stall failures depth misses.
Span link
The trace relationship connecting a consumer's span to the producer's across the async boundary.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.