Skip to content

    AI Architect

    Multi-Agent and Event-Driven AI Architecture

    When several agents beat one, and how to run them over events safely: partitions, offsets, idempotency keys, outbox, sagas and dead-letter queues.

    By Published 34 min read

    Share

    This lesson is part of AI Architect: From AI Engineer to Expert. Enroll for free, then sign in, to read it in full.

    What you'll learn: when more than one agent is worth its cost and when a single agent or a fixed workflow is the better design, and how to connect agents through events without duplicate side effects. You will follow one event through Kafka partitions to a consumer group, work out what a restart does to it, and learn what "exactly-once" does and does not cover. You will then apply idempotency keys, the transactional outbox, sagas with compensating actions, and dead-letter queues, with worked numbers for each.

    Much of what goes wrong when agents are connected by events is not about prompts. It is the ordinary distributed-systems list: duplicates, lost messages, reordering, half-finished transactions and queues nobody watches. This page keeps the AI part small and the distributed part exact.

    The real-world problem

    Composite scenario, not a real company. An online marketplace handles returns with three steps connected by an event stream. An intake agent classifies each return request, a resolution agent proposes a refund or a replacement, and an execution step posts the refund through a payments tool. During a routine deployment all worker pods restart. The stream replays the last few minutes of events, and some customers receive the refund twice. A week later a refund that a person approved is posted after the customer has already cancelled the order.

    No prompt was at fault. The design assumed that every event arrives once, in order, and that repeating a model call is harmless. None of those holds for an event stream, and the second incident shows that a long-running, multi-step action has no automatic rollback. The fixes are old and well documented: keys that make repeats safe, a way to publish events atomically with state changes, compensating actions for half-finished work, and a defined place for messages that cannot be processed.

    After this page you will be able to

    • Decide, with the criteria published in Anthropic's engineering posts, whether a task needs one agent, a fixed workflow or an orchestrator with workers, and state the cost of that choice.
    • Trace one event through partitions to a consumer group, compute partition assignment and consumer lag from stated numbers, and say what a restart does under each offset-commit order.
    • State what Kafka documents about delivery semantics, the idempotent producer and transactions, and design an idempotent consumer so that a side effect is applied once even when delivery is repeated.
    • Choose between idempotency keys, an outbox, a saga and a dead-letter queue for a given failure, and place each one on an architecture diagram.
    • Define what ordering you promise (per key, per partition) and what a poison message does to it.

    Read Agents, tools and MCP first: it covers the single-agent loop each Worker runs. Diagramming AI systems introduced the retry and idempotency-key sequences this page extends. For implementation walk-throughs see Event-driven AI with Kafka and Multi-agent Java and Kafka; for general theory, event streaming, distributed transactions and AI system resilience.

    Terms used on this page: orchestrator-worker, handoff, consumer group, offset, consumer lag, effectively-once processing, idempotency key, transactional outbox, saga, compensating action and dead-letter queue.

    Core concepts

    When more than one agent helps, and when it does not

    Anthropic's "Building effective agents" (December 2024) separates workflows, "systems where LLMs and tools are orchestrated through predefined code paths", from agents, "systems where LLMs dynamically direct their own processes and tool usage". It recommends "finding the simplest solution possible, and only increasing complexity when needed", and notes that agentic systems "often trade latency and cost for better task performance" and carry "the potential for compounding errors".

    Anthropic's engineering post on its multi-agent research system (13 June 2025) gives the conditions under which several agents paid off in a research product. It says multi-agent systems "excel at valuable tasks that involve heavy parallelization, information that exceeds single context windows, and interfacing with numerous complex tools". The same post gives the cost and the poor fits:

    • "In our data, agents typically use about 4× more tokens than chat interactions, and multi-agent systems use about 15× more tokens than chats." Reading the first ratio as a single-agent run, a multi-agent run uses about 15 / 4 = 3.75 times as many tokens for the same job (a derived figure, not one the post states; tokens are not price).
    • "For economic viability, multi-agent systems require tasks where the value of the task is high enough to pay for the increased performance."
    • Domains that need all agents to share the same context, or that have many dependencies between agents, are a poor fit; the post says coding has fewer parallelisable tasks than research.
    • Early versions made mistakes such as "spawning 50 subagents for simple queries", and one case of subagents duplicating each other's work for lack of a clear division of labour.

    A simple model shows why chains are fragile. If each step succeeds independently with probability p, n steps succeed with probability p^n: 0.98^5 = 0.9039 (about 90%) and 0.95^10 = 0.5987 (about 60%). Real steps are not independent, so this is the smallest model of the post's statement that "errors compound", not a forecast.

    Figure 1. Do I need more than one agent?

    flowchart TD accTitle: Choosing between a workflow, one agent and several agents accDescr: A chain of questions leads from a fixed workflow, to a single agent with tools, to an orchestrator with workers. Several agents are chosen only when subtasks are independent, parallelisable and valuable enough to pay for the extra tokens. Tasks whose parts share context or depend on each other stay with one agent. Start(["A task needs an LLM"]) Q1{"Can fixed code paths of LLM calls do it?"} Q2{"Does one agent fit in one context and budget?"} Q3{"Are the subtasks independent and parallelisable?"} Q4{"Is the result valuable enough for roughly 3 to 4 times the tokens?"} A1["Workflow: predefined steps"] A2["Single agent with tools"] A3["Orchestrator with workers"] A4["Single agent, improve tools and prompt"] Start --> Q1 Q1 -->|"yes"| A1 Q1 -->|"no"| Q2 Q2 -->|"yes"| A2 Q2 -->|"no"| Q3 Q3 -->|"no, parts share context or depend on each other"| A4 Q3 -->|"yes"| Q4 Q4 -->|"yes"| A3 Q4 -->|"no"| A4

    How to read this: start at the top, follow yes or no at each diamond, and stop at the first rectangle; the 3 to 4 times in the last question is the 15 / 4 = 3.75 ratio derived above.

    Orchestrator-worker

    The research post's design is orchestrator-worker: "a lead agent coordinates the process while delegating to specialized subagents that operate in parallel". "Building effective agents" calls it "a central LLM dynamically breaks down tasks, delegates them to worker LLMs, and synthesizes their results", suited to tasks "where you can't predict the subtasks needed". The research post says the lead agent saves its plan to memory because context past 200,000 tokens is truncated, and that lead agents run subagents synchronously, which "creates bottlenecks in the information flow between agents"; asynchronous execution is named as a future improvement.

    An Event bus between Orchestrator and Workers is one way to get that asynchrony. It also means every hop can be delivered twice, late or out of order, which is the rest of this page.

    Figure 2. Orchestrator and Workers connected by an Event bus

    flowchart LR accTitle: Orchestrator and Workers connected by an Event bus accDescr: A Client app calls the API gateway, which passes a job with a verified identity to the Orchestrator. The Orchestrator publishes task events to the Event bus, a Worker consumes them, calls the LLM provider through the Model gateway and a Tool server with an idempotency key, and publishes result events back. The Orchestrator asks the Approval service for decisions and both Orchestrator and Worker send traces to the Trace store. Client["Client app"] GW["API gateway"] subgraph SYS["Our systems"] Orch["Orchestrator: plans, splits the job, merges results"] EB["Event bus: events keyed by job id"] Wk["Worker: runs one subtask"] MG["Model gateway"] TS["Tool server"] AP["Approval service"] TR[("Trace store")] end LLM["LLM provider (external)"] Client -->|"HTTPS"| GW GW -->|"job with verified identity"| Orch Orch -.->|"task events"| EB EB -.->|"deliver task"| Wk Wk -->|"prompt"| MG MG -->|"HTTPS"| LLM Wk -->|"tool call with idempotency key"| TS Wk -.->|"result event"| EB EB -.->|"deliver result"| Orch Orch -->|"approval request"| AP Orch -.->|"trace"| TR Wk -.->|"trace"| TR

    How to read this: solid arrows are synchronous calls, dotted arrows are asynchronous events or traces, cylinders are stores, and the large box is the system the team runs.

    Handoffs

    A handoff transfers a task from one agent to the next with no coordinator staying in control (this course's meaning; products use the word differently). It fits a pipeline of specialists. Its risks: context is lost or bloated at each hop, so pass a structured case record or a reference, as the research post does when subagents write outputs to external storage and pass lightweight references back to avoid a "game of telephone"; control can loop, so carry a hop counter; and nobody owns the outcome, which is why this page prefers an Orchestrator when a job has one accountable result.

    Event-driven decoupling

    An event states a fact that happened (refund.proposed); a command asks for something (post_refund). Agents are slow, rate-limited consumers, so an Event bus absorbs bursts as backlog instead of timeouts, lets Workers scale and deploy independently, and with a log such as Kafka allows replay, because "events are not deleted after consumption" and retention is a per-topic setting (Apache Kafka introduction). The costs are eventual consistency, duplicates, ordering limited to a partition, and harder debugging. Use it when work can wait; keep a synchronous call when the user is blocked on the answer.

    Exactly-once myths

    Myth What the sources say
    "Kafka is exactly-once, so my consumer needs no idempotency." The design documentation says Kafka guarantees at-least-once delivery by default; exactly-once is described for Kafka Streams and for the transactional producer with read-committed consumers, and for external systems it generally needs the destination's cooperation.
    "The idempotent producer removes duplicates end to end." The producer documentation guarantees it only "within a single session" of the producer, for the client's own retries; a consumer that reprocesses after a restart is not covered.
    "Events are globally ordered." A consumer reads a partition's events in the order written; there is no order across partitions.
    "Repeating the model call is safe." A model rule, not a Kafka one: a second call can return different text, so store the result against the event id and reuse it.

    Human-in-the-loop in async flows

    A person is another slow consumer. Model the wait as a state in a durable record, never a thread blocked on a reply, with a deadline and a defined outcome when it passes (here, expire and do nothing). The approver sees the exact action and its evidence, and the action runs with a key derived from the event id, so a double click or a redelivered approval cannot run it twice.

    Figure 3. Asynchronous refund chain with a human approval

    sequenceDiagram accTitle: Asynchronous refund chain with human approval accDescr: The Client app submits a return request. The Orchestrator publishes an event, a Worker classifies it and proposes a refund, and the Orchestrator asks the Approval service when the amount is over the limit. If a person decides in time, the Orchestrator posts the refund with an idempotency key derived from the event id. If not, the request expires and nothing is posted. autonumber participant C as Client app participant O as Orchestrator participant B as Event bus participant W as Worker participant A as Approval service participant T as Tool server C->>O: return request with request id r1 O-)B: event return.requested, key order-42, event id e1, correlation id r1 B-)W: deliver e1 Note over W: Agent step - classify the return and propose a refund W-)B: event refund.proposed, key order-42, event id e2, correlation id r1 B-)O: deliver e2 O->>A: approval request, amount over the limit alt A person decides before the deadline A-->>O: approved O->>T: post_refund with idempotency key e2 T-->>O: refund id O-)B: event refund.completed else No decision before the deadline Note over O: Decision - expire the request and do not act O-)B: event refund.expired end O-->>C: outcome at the status endpoint

    How to read this: time runs downward, open-headed arrows are asynchronous events, solid arrows are requests, and every event carries the same correlation id r1 and its own event id.

    Figure 4. States of one refund request

    stateDiagram-v2 accTitle: States of one refund request accDescr: A request is submitted, then proposed by a Worker. A refund within the limit goes straight to execution. A refund over the limit waits for approval and ends approved, rejected or expired. Execution ends done or failed. Every path reaches a terminal state. [*] --> Submitted Submitted --> Proposed: Worker emits refund.proposed Proposed --> Executing: amount within the limit Proposed --> WaitingApproval: amount over the limit WaitingApproval --> Executing: approved WaitingApproval --> Rejected: rejected by the approver WaitingApproval --> Expired: deadline passed Executing --> Done: tool call confirmed Executing --> Failed: tool call fails after retries Done --> [*] Rejected --> [*] Expired --> [*] Failed --> [*]

    How to read this: each box is a state of the durable request record, each arrow is a transition with its trigger, and the ringed dot marks the end.

    Observability of async chains

    An event chain leaves unconnected log lines unless you connect them. Carry a correlation id in every event (Figure 3 uses r1), log topic, partition, offset and event id at each hop, alert on consumer lag per partition and on dead-letter depth, and keep the request's state record as the place to ask "where is this job?". The research post describes full production tracing and monitoring of decision patterns without monitoring conversation contents. See LLM observability; tracing gets a full page in the next part.

    Case files

    Three case files show the patterns under pressure. The first two are composite scenarios; the third is a public case, based only on what Anthropic published.

    Under the hood: Partitions, consumer groups and offsets

    Intuition

    Picture several notebooks with numbered pages. Writers only append; each page number is an offset. A team of readers shares the notebooks, each notebook is read by one member at a time, and that member keeps a bookmark with the next page to read. If a reader leaves, someone else takes the notebook from the last saved bookmark. That is a topic with partitions, a consumer group and committed offsets.

    Mechanism

    The Kafka documentation states the pieces precisely. A topic is spread over a number of "buckets" called partitions, and each new event is appended to one of them. Events with the same event key (the introduction's examples are a customer or vehicle id) "are written to the same partition", and Kafka guarantees that "any consumer of a given topic-partition will always read that partition's events in exactly the same order as they were written". Consumers sharing a group.id form a consumer group, and each partition is "consumed by exactly one consumer within each subscribing consumer group at any given time". Because of that, a consumer's position in a partition is "a single integer, the offset of the next message to consume". The committed position is the stored offset a restarted consumer resumes from. When a member joins or fails, the group rebalances and partitions move between members.

    Figure 5. One event through partitions to a consumer group

    sequenceDiagram accTitle: An event through partitions to a consumer group accDescr: The Orchestrator appends two events with the key claim-77 to Partition 1 and one event with the key claim-12 to Partition 0. Worker A owns Partition 0 and Worker B owns Partition 1, both in one consumer group. Worker B reads offsets 41 then 42 in write order, processes them, and commits offset 43. Order is guaranteed only inside a partition. autonumber participant O as Orchestrator participant P0 as Partition 0 participant P1 as Partition 1 participant K as Group offsets participant WA as Worker A participant WB as Worker B O->>P1: append event key claim-77, assigned offset 41 O->>P0: append event key claim-12, assigned offset 90 O->>P1: append event key claim-77, assigned offset 42 Note over P0,P1: Each partition is its own ordered log, with no order across partitions Note over WA,WB: One consumer group - Partition 0 is assigned to A and Partition 1 to B WB->>K: where is my committed offset for Partition 1 K-->>WB: 41 WB->>P1: poll from offset 41 P1-->>WB: records 41 then 42, in write order Note over WB: Process 41, then 42, and apply their effects WB->>K: commit offset 43 for Partition 1 WA->>P0: poll from its committed offset P0-->>WA: record 90

    How to read this: time runs downward, each partition is a separate lifeline with its own offsets, and "Group offsets" is the stored committed position that survives a Worker restart.

    Figure 6. Six partitions shared by four Workers

    flowchart LR accTitle: Six partitions assigned to four Workers accDescr: The topic has six partitions. Worker 0 owns partitions 0 and 1, Worker 1 owns partitions 2 and 3, Worker 2 owns partition 4 and Worker 3 owns partition 5. A fifth Worker would receive no partition. subgraph T["Topic claim-events: 6 partitions"] P0["Partition 0"] P1["Partition 1"] P2["Partition 2"] P3["Partition 3"] P4["Partition 4"] P5["Partition 5"] end subgraph G["Consumer group claim-workers: 4 Workers"] W0["Worker 0"] W1["Worker 1"] W2["Worker 2"] W3["Worker 3"] end P0 -->|"assigned"| W0 P1 -->|"assigned"| W0 P2 -->|"assigned"| W1 P3 -->|"assigned"| W1 P4 -->|"assigned"| W2 P5 -->|"assigned"| W3

    How to read this: each arrow is one partition owned by one Worker; no partition has two owners in the same group.

    Worked numbers. Assignment depends on the configured strategy; the range assignor, one of the strategies Kafka lists, divides partitions by consumers and, in its words, "the first few consumers will have one extra partition" when the division is uneven. For 6 partitions and 4 Workers, 6 = 4 x 1 + 2, so Workers 0 and 1 own two partitions each and Workers 2 and 3 own one, as in Figure 6. With 8 Workers, Workers 6 and 7 own none. Lag: the Kafka tooling shows CURRENT-OFFSET, LOG-END-OFFSET and LAG per partition, where the operations documentation describes lag as "how far behind the end of the log" a group is; reading it as log end minus committed offset is this page's interpretation of those columns. Assume three partitions with log-end offsets 1200, 1180 and 1260 and committed offsets 1150, 1180 and 1100. Lags are 50, 0 and 160, total 210. If events arrive at 30 per second and the group consumes 45 per second, the backlog drains at 45 - 30 = 15 per second, so 210 / 15 = 14 seconds.

    What this means for architects

    • Parallelism is capped by the partition count. Extra Workers sit idle. Because model calls are slow, plan partitions as concurrency, not storage (see the worked example).
    • The key is the ordering promise. Choose the smallest entity whose events must stay in order (an order id, not a customer id). A skewed key piles work onto one partition.
    • Slowness shows up as lag, not errors. Alert on lag per partition and its trend; total lag can look small while one partition is far behind.
    • A missing offset has a default. auto.offset.reset defaults to latest and applies when the stored offset no longer exists, so a group down longer than the retention can silently skip to the end. Prefer none (it throws) for work you cannot lose.
    • Rebalances pause work. A Worker that does not call poll within max.poll.interval.ms (default 300000 ms, Kafka 4.3 documentation as of 2026-10-21) is treated as failed, which matters when one batch holds many LLM calls.

    Under the hood: At-least-once and effectively-once processing

    Intuition

    Delivery guarantees are statements about a crash at the worst moment. A consumer reads a record, does the work and saves its place. Save first and a crash loses work; work first and a crash repeats work. "Once" needs the work and the saved place to change together, or repeating the work to be harmless.

    Mechanism

    The Kafka design documentation describes both orders. Reading, saving the position, then processing is "at-most-once" because after a consumer failure messages "may not be processed". Reading, processing, then saving the position gives at-least-once: a crash after processing but before saving makes the replacement's first messages ones that were already processed. The same section says Kafka "guarantees at-least-once delivery by default", and that idempotent updates (overwriting a record by primary key) make the duplicates harmless.

    On the producer side, the Kafka documentation says the idempotent producer means retries no longer introduce duplicates into the log; the producer documentation limits that to "a single session" and says enable.idempotence defaults to true from Kafka 3.0 and requires acks=all, retries above 0 and at most five in-flight requests per connection. Transactions let a producer write to several partitions atomically, and a consumer with isolation.level=read_committed sees only committed transactional messages. For consume-transform-produce between Kafka topics, the producer's sendOffsetsToTransaction makes the consumed offsets count as committed only if the transaction commits. The design documentation says exactly-once delivery is supported inside Kafka Streams and through the transactional producer with read-committed consumers, and that for an external system it "generally requires cooperation" from that system. A refund posted over HTTP is an external system.

    Figure 7. A Worker restart under each offset-commit order

    sequenceDiagram accTitle: Worker restart under two offset-commit orders accDescr: A Worker polls records 100 to 104 and processes 100 to 102 before crashing. If it commits after processing, the committed offset is still 100, so the restarted Worker re-reads 100 to 104 and a dedupe check skips 100 to 102. If it committed before processing, the committed offset is 105 and records 103 and 104 are never processed. autonumber participant W as Worker participant B as Event bus participant T as Tool server participant W2 as Restarted Worker W->>B: poll, receives records 100 to 104 alt Offset committed after processing W->>T: apply effects for 100, 101, 102 Note over W: Crash before the batch is committed Note over B: Committed offset is still 100 W2->>B: where do I resume B-->>W2: offset 100 W2->>T: records 100 to 102 repeat, the dedupe check skips them W2->>T: apply effects for 103, 104 W2->>B: commit offset 105 else Offset committed before processing W->>B: commit offset 105 W->>T: apply effects for 100, 101, 102 Note over W: Crash while working on 103 W2->>B: where do I resume B-->>W2: offset 105 Note over W2: Records 103 and 104 are never processed end

    How to read this: the alt block shows the same crash twice; the top branch repeats work that dedupe can absorb, the bottom branch loses work that nothing can recover.

    Worked numbers. A batch is 5 records (offsets 100 to 104) and the crash comes after 3 are processed. Commit after processing: the replacement re-reads all 5; with a dedupe check 3 repeats are skipped and 2 effects applied. Commit before processing: it starts at 105, so 105 - 103 = 2 records are lost. Duplicates after a crash are bounded by the records handled since the last commit: at most one batch of max.poll.records if you commit after every batch.

    "Effectively-once" here means at-least-once delivery plus an idempotent effect, so the effect lands once though the message may arrive twice. The design documentation discusses two ways to build it: store the consumer's position in the same transaction as the output, or make the write idempotent.

    What this means for architects

    • Default to commit-after-processing plus dedupe. Losing a claim or a refund is worse than repeating one, and dedupe is a table with a unique key.
    • Dedupe before the model call. A duplicate that reaches the Model gateway pays for tokens and may return different text; store the result against the event id and reuse it. Price the repeats with the cost estimator.
    • Name the scope of "exactly-once". For a Kafka-to-Kafka stage it is real; for the payments call it is your idempotency design.
    • Auto-commit is risky with slow work. enable.auto.commit defaults to true with a 5000 ms interval, and the consumer documentation warns that the committed offset can run ahead of the work if a poll's records are not all consumed first. Turn it off for agent Workers.

    Under the hood: Idempotency keys

    Intuition

    An idempotency key is a receipt number the caller attaches to a request. Show the same receipt twice and the server returns the first outcome instead of acting again. That makes the retry a timeout forces on you safe, because a timeout does not say whether the first attempt happened.

    Mechanism

    Stripe documents one concrete design for its v1 API. The client generates the key (V4 UUIDs are suggested; keys are up to 255 characters) and sends it in the Idempotency-Key header. Stripe saves "the status code and body of the first request made with a given idempotency key after endpoint execution begins", and retries with the same key return the saved response, "including 500 errors". The layer compares incoming parameters with the original request and errors if they differ. Results are saved only once execution begins, so a request that fails validation, or conflicts with a concurrent one, is not saved and can be retried. Keys may be removed once they are at least 24 hours old, and a key reused after it has been removed starts a new request. Only POST needs a key; GET and DELETE are idempotent by definition. The page states that API v2 behaves differently: retries do not repeat successful operations, and Stripe completes failed or partial work where it can.

    There is also an IETF specification effort. The datatracker page shows draft-ietf-httpapi-idempotency-key-header as an Expired Internet-Draft of the httpapi working group, state "Expired & archived", revision 07 dated 2025-10-15, with the notice that it is no longer active. It is therefore not a standard, and the draft's own boilerplate says Internet-Drafts are not appropriate as reference material except as work in progress. Its text is still useful as a design: the header is a String that must be unique and must not be reused with a different payload, the draft specifies HTTP 400 when a required key is missing, 422 when the same key arrives with a different payload, and 409 when the original request is still being processed, and a resource that expires keys should publish its policy.

    Figure 8. Life of one idempotency key record

    stateDiagram-v2 accTitle: Life of one idempotency key record accDescr: A key starts with no record. The first request stores the key with a fingerprint of the payload and the record is in progress. When the operation finishes the response is saved and the record is completed. The same key during processing returns a conflict, the same key with a different payload is rejected, and the same key with the same payload returns the saved response. After the retention period the record disappears. [*] --> NoRecord NoRecord --> InProgress: first request, key and payload fingerprint stored InProgress --> Completed: operation finishes and response saved InProgress --> InProgress: same key while running returns 409 InProgress --> NoRecord: failure before any effect, reservation released Completed --> Completed: same key and same fingerprint returns the saved response Completed --> Completed: same key and different fingerprint returns 422 Completed --> NoRecord: retention period over

    How to read this: each box is the state of the stored record for one key, and each arrow says what a request or the clock does to it; the 409 and 422 codes are the ones the expired draft specifies.

    Figure 9. A retried tool call with the same key

    sequenceDiagram accTitle: Retried tool call with the same idempotency key accDescr: The Orchestrator calls the Tool server with key e2. The Tool server reserves the key in an idempotency store, creates the refund at the payments system passing the key along, and saves the response. The reply is lost, the Orchestrator retries with the same key, and the Tool server returns the saved refund instead of creating another. autonumber participant O as Orchestrator participant T as Tool server participant S as Idempotency store participant P as Payments system O->>T: post_refund order-42, key e2 T->>S: reserve key e2 with payload fingerprint S-->>T: reserved, no earlier record T->>P: create refund and pass key e2 along P-->>T: refund r-77 created T->>S: save response for key e2 Note over O,T: The reply to the Orchestrator is lost, so it times out and retries O->>T: post_refund order-42, key e2, same payload T->>S: look up key e2 S-->>T: completed, saved response refund r-77 T-->>O: refund r-77, nothing is created again Note over T,P: If the Tool server crashed before step 6, the store would show in progress, and only the key passed to the payments system prevents a second refund

    How to read this: steps 1 to 6 are the first attempt, steps 7 to 10 the retry, and the last note marks the crash window that a key store alone cannot close.

    Worked numbers. Suppose the key store keeps keys for 24 hours and the topic keeps events for 7 days. A Worker paused for 2 days and then replaying reaches events whose keys have expired, so an effect can run again. Key retention must cover the longest period over which one event can be redelivered or replayed; 24 hours is less than the 48-hour pause, so extend it or store the key with the business record.

    What this means for architects

    • Derive the key from the event, not the attempt. A key made fresh on each retry defeats the purpose; use the event id or a hash of the business identity.
    • Honour the key where the effect happens. Reserve it atomically (a unique constraint) and write it in the same transaction as the effect, or pass it to the system that performs the effect, as Figure 9's last note says.
    • Return the saved response. It is also the cleanest answer to LLM non-determinism: a second delivery receives the first outcome and does not ask the model again.
    • A key has an expiry. Publish the retention, decide what a late duplicate does, and treat 409 and 422 as expected outcomes.

    Under the hood: The outbox pattern

    Intuition

    A service that changes its database and then publishes an event makes two writes to two systems, and a crash can fall between them. Publish first and the event can announce something that never committed; commit first and the event can be lost. The outbox makes the second write part of the first.

    Mechanism

    The microservices.io pattern states the problem as "How to atomically update the database and send messages to a message broker?" The solution: the service stores each message in an outbox table, in the same local transaction that updates its business entities, and a separate message relay reads the outbox and publishes to the broker. The page names two ways to build the relay, a polling publisher and transaction log tailing, and notes that two-phase commit is not an option when the database or broker does not support it. Its stated consequence is that messages are sent "if and only if the database transaction commits", in the order the application sent them. It also states the price: the relay may publish a message more than once (for example after a crash between publishing and recording that it did), so consumers must be idempotent.

    Figure 10. Outbox with a polling relay and a duplicate publish

    sequenceDiagram accTitle: Outbox with a polling relay accDescr: The Orchestrator updates the refund case and inserts an outbox row in one database transaction. A relay polls the outbox, publishes the event to the Event bus, and marks the row sent. If the relay crashes after publishing and before marking the row, it publishes the same event again after restart and the Worker's dedupe check absorbs the duplicate. autonumber participant O as Orchestrator participant D as Service database participant R as Outbox relay participant B as Event bus participant W as Worker O->>D: one transaction - update refund case and insert outbox row e9 D-->>O: committed R->>D: poll for unsent rows D-->>R: row e9 R-)B: publish event e9 Note over R: Crash before the row is marked sent R->>D: after restart, poll for unsent rows D-->>R: row e9 again R-)B: publish event e9 again R->>D: mark e9 sent B-)W: deliver e9 twice Note over W: The dedupe check on event id e9 applies the effect once

    How to read this: steps 1 and 2 are the single local transaction, steps 3 to 5 are the first publish, steps 6 to 9 the retry after the crash, and the final note is the consumer's protection.

    Worked numbers. A polling relay with interval T adds delay between commit and publish. If commits fall uniformly within the cycle, the mean added delay is T / 2 and the worst case is T plus query and publish time. With T = 500 ms the mean is 250 ms: fine beside a model call of seconds, not within a budget of a few hundred milliseconds, where the page's other option, log tailing, fits better.

    What this means for architects

    • Use an outbox when state and event must agree. A refund case and its refund.proposed event must not disagree. If losing an event is acceptable (a metrics ping), skip the cost.
    • The outbox row id is the event id. It travels in the header and is the dedupe key for consumers and the idempotency key for the tool call.
    • Order comes from the key. Publish rows per aggregate in order and key events by aggregate id, so Kafka keeps them ordered within a partition.
    • Monitor the relay. Alert on the age of the oldest unsent row and purge sent rows on a schedule.

    Under the hood: Sagas and compensating actions

    Intuition

    A refund that touches payments, inventory and a carrier cannot be one database transaction, because different services own them. A saga replaces the single rollback with a sequence of local transactions and, for each, a business action that undoes it. If step 3 fails, undo step 2, then step 1.

    Mechanism

    The idea is from Hector Garcia-Molina and Kenneth Salem, "Sagas" (SIGMOD 1987; also Princeton technical report TR-070-87, January 1987). The report's abstract says a long-lived transaction is a saga if it can be written as a sequence of transactions that can be interleaved with other transactions, that the database system guarantees either all of a saga's transactions complete or compensating transactions undo the partial work, and that the motivation is that long-lived transactions hold database resources for long periods and delay shorter ones. This page relies on the abstract.

    The microservices.io saga page states the modern form: "a reliably coordinated sequence of local transactions", coordinated either by choreography (each service publishes events that trigger the next) or by orchestration (a coordinator tells participants what to execute). It lists the costs: no automatic rollback, so a developer "must design compensating transactions that explicitly undo changes"; no isolation, so concurrent sagas and transactions can cause data anomalies; the need to update a database and publish a message atomically (which the outbox addresses); and a client that needs a way to learn the outcome.

    Figure 11. States of an orchestrated saga

    stateDiagram-v2 accTitle: States of an orchestrated saga accDescr: The saga holds funds, then reserves stock, then creates a shipment, and completes. If reserving stock fails, it releases the funds and aborts. If creating the shipment fails, it releases the stock and then the funds, in reverse order, and aborts. If holding funds fails there is nothing to undo. [*] --> FundsHeld: T1 hold funds [*] --> Aborted: T1 fails, nothing to undo FundsHeld --> StockReserved: T2 reserve stock StockReserved --> ShipmentCreated: T3 create shipment ShipmentCreated --> Completed FundsHeld --> ReleasingFunds: T2 fails StockReserved --> ReleasingStock: T3 fails ReleasingStock --> ReleasingFunds: C2 stock released ReleasingFunds --> Aborted: C1 funds released Completed --> [*] Aborted --> [*]

    How to read this: forward steps are T1 to T3, compensations are C1 and C2 run in reverse order, and the two terminal states are Completed and Aborted.

    Figure 12. Saga failure at step 3 and its compensations

    sequenceDiagram accTitle: Saga failure at step 3 with compensations accDescr: The Orchestrator holds funds at the Payments tool and reserves stock at the Inventory tool. Creating the shipment at the Carrier tool fails after retries, so the Orchestrator releases the stock and then the funds, each with an idempotency key, and publishes a saga aborted event. A failed compensation is retried until it succeeds and then escalated to a person. autonumber participant O as Orchestrator participant PAY as Payments tool participant INV as Inventory tool participant CAR as Carrier tool participant B as Event bus O->>PAY: T1 hold funds for order-42, key s1-t1 PAY-->>O: hold h1 placed O->>INV: T2 reserve stock, key s1-t2 INV-->>O: reservation v4 made O->>CAR: T3 create shipment, key s1-t3 CAR-->>O: error, retries exhausted Note over O: Decision - T3 failed, run compensations in reverse order O->>INV: C2 release reservation v4, key s1-c2 INV-->>O: released O->>PAY: C1 release hold h1, key s1-c1 PAY-->>O: released O-)B: event saga.aborted Note over O: A failed compensation is retried until it succeeds, then escalated to a person

    How to read this: steps 1 to 6 are forward steps, the note marks the decision, steps 7 to 10 undo the work in reverse order, and each call carries its own idempotency key.

    Worked numbers. With three steps, each succeeding independently with probability p = 0.98, the saga completes with probability 0.98^3 = 0.941192, so 1 - 0.941192 = 0.058808 of sagas fail somewhere. A failure at step k runs k - 1 compensations, and the probability of first failing at step k is p^(k-1) x (1 - p): 0.02, 0.0196 and 0.019208 for k = 1, 2, 3. Expected compensations per saga: 0 x 0.02 + 1 x 0.0196 + 2 x 0.019208 = 0.058016. About 6 sagas in 100 fail somewhere, and 0.0196 + 0.019208 = 0.038808, about 4 in 100, fail after step 1 and so need at least one compensation. At volume the compensation path runs every day.

    What this means for architects

    • Order steps by reversibility. Easy-to-undo steps first (holds, reservations), irreversible ones last (a notification cannot be unsent). This is a design rule, not something the sources prescribe.
    • Compensations must be idempotent and retried to completion. There is no further rollback if an undo fails, so give it an escalation path to a person.
    • Design for the intermediate state. With no isolation, another request can see a held amount; mark such records pending so readers and agents treat them as provisional.
    • Prefer orchestration for agent flows. One Orchestrator keeps the saga state in one place and makes the human decision (Figure 4) a state in that record; choreography spreads the logic across services.

    Under the hood: Dead-letter queues and ordering

    Intuition

    A poison message fails every time. Retry it forever and the line behind it stops; skip it and the work is lost or, worse, later messages for the same entity are applied before it. A dead-letter queue is where the message goes so the line moves, with a person or tool responsible for what lands there.

    Mechanism

    Amazon SQS documents the standard form. Dead-letter queues are targets that source queues use for messages "that are not processed successfully", useful for debugging. A redrive policy sets maxReceiveCount, the number of times a consumer can receive a message before it moves to the dead-letter queue, and the page advises setting it high enough to allow sufficient retries. For standard queues the enqueue timestamp is unchanged when a message moves, so the best practice is a dead-letter retention period longer than the source queue's. Ordering is where the guidance turns cautious: "Don't use a dead-letter queue with a FIFO queue if you don't want to break the exact order of messages or operations". FIFO ordering holds within a message group ID, and no further messages from that group are returned until the received message is deleted or becomes visible again, so one failing message blocks its group while other groups continue.

    Kafka's design differs. Ordering is per partition, and a consumer either commits past a failing record or does not. The consumer documentation read for this page describes no dead-letter feature, while Kafka Connect has one: its user guide says connectors fail fast by default (errors.tolerance=none) and routes failed records to a topic when errors.deadletterqueue.topic.name is set. For your own consumers, the usual design is to publish the failing record, with its key and error context, to a dead-letter topic and then commit.

    Figure 13. Life of one message with retries and a dead-letter queue

    stateDiagram-v2 accTitle: Life of one message with retries and a dead-letter queue accDescr: A received message is processed. Success commits the offset. A transient failure waits with backoff and retries while attempts remain. A non-retryable failure, or exhausted attempts, sends the message to the dead-letter queue and then the offset moves on. [*] --> Received Received --> Processing Processing --> Committed: success, offset committed after Processing --> RetryWait: transient failure and attempts left RetryWait --> Processing: backoff elapsed Processing --> DeadLettered: non-retryable failure or attempts exhausted DeadLettered --> Committed: parked with error context, offset moves on Committed --> [*]

    How to read this: each box is a state of one message, and every path ends in Committed, so the line always moves.

    Figure 14. A poison message on one partition, with per-key quarantine

    sequenceDiagram accTitle: Poison message with per-key quarantine accDescr: A Worker receives offsets 7 and 8 with key claim-5 and offset 9 with key claim-9. Offset 7 fails and then fails again on five retries with growing waits, then is parked in the dead-letter queue. Offset 8 has the same key, so it is parked too, which keeps the order for claim-5. Offset 9 is processed normally and the Worker commits offset 10. autonumber participant B as Event bus participant W as Worker participant T as Tool server participant D as Dead-letter queue B-->>W: offsets 7 and 8 key claim-5, offset 9 key claim-9 loop Five retries, waiting 1, 2, 4, 8 and 16 seconds W->>T: process offset 7 T-->>W: error end Note over W: Decision - 31 seconds of waiting spent, park offset 7 and quarantine key claim-5 W->>D: park offset 7 with key and error context W->>D: park offset 8, same key, to keep claim-5 in order W->>T: process offset 9, key claim-9 T-->>W: ok W->>B: commit offset 10

    How to read this: the loop is the retry policy, the note is the decision, and the two parked records keep the order of events for claim-5 while claim-9 goes on.

    Worked numbers. Five retries that wait 1, 2, 4, 8 and 16 seconds stall a partition for 1 + 2 + 4 + 8 + 16 = 31 seconds, not counting the attempts themselves. At 20 events per second over 48 partitions, a partition receives 20 / 48 = 0.4167 per second, so about 31 x 0.4167 = 12.9, roughly 13 events queue behind the poison message. If you park it and carry on, any of those sharing key claim-5 would be applied ahead of the parked record unless you quarantine the key, as Figure 14 does.

    What this means for architects

    • Classify failures first. A rate limit or timeout is transient, so retry with backoff; a schema or validation failure never succeeds, so park it at once.
    • Choose the ordering promise per flow. For a saga or a ledger, block or quarantine by key; for independent items such as documents, park and move on.
    • A dead-letter queue needs an owner. Alarm on depth and age, keep a runbook and a replay tool; replay is safe only because consumers are idempotent.
    • It is sensitive data. Parked events hold prompts, customer text and model output; give the queue the access control and retention of the system it came from.

    Decision framework

    1. If the task cannot be split into independent parallel subtasks, or its parts share context, then use one agent or a fixed workflow, because the research post names shared context and many dependencies as a poor fit and reports about 15 times a chat's tokens; revisit when you can measure that the extra cost buys more value.
    2. If work can wait seconds and arrives in bursts, then put an Event bus between Orchestrator and Workers, because it absorbs bursts and lets each side scale and fail independently; revisit when the user must wait inline.
    3. If delivery can repeat (it can, with at-least-once), then commit offsets after processing and make the effect idempotent with a key derived from the event id, because a lost refund is worse than a repeated one; revisit only for Kafka-to-Kafka stages that use transactions.
    4. If a state change and an event must agree, then write both in one local transaction through an outbox, because a separate publish can lose or invent events; revisit if your broker and database support an atomic write.
    5. If an action spans services and some steps cannot be undone, then use a saga with idempotent compensations and irreversible steps last, because there is no distributed rollback; revisit when a step needs a human decision first.
    6. If one message can block a partition, then define retry limits, a dead-letter queue and a per-key ordering rule, because unbounded retry stalls every key on that partition; revisit when dead-letter depth stays above zero.

    Comparison of the main options

    Option Ordering Replay Failure handling Best for Main cost
    Direct synchronous calls between agents Call order in one request None Caller's timeout and retry User-blocking steps, short chains Failures cascade up the chain
    Queue where order does not matter None promised Limited to what is retained Visibility timeout, retries, dead-letter queue Independent items such as document classification Duplicates and out-of-order handling
    FIFO queue with message groups (SQS FIFO) Strict within a message group Limited A failing message blocks its group Per-entity ordering with moderate throughput Head-of-line blocking; dead-letter queue can break order
    Partitioned log (Kafka) Per partition, same key to same partition Yes, within retention Consumer decides commit and parking High-volume event flows and replay Operations, partition planning, dead-letter handling written by the application
    Orchestrator with a durable state record Whatever the Orchestrator enforces From the record Saga steps and compensations Multi-step actions with approvals You build and own the state machine

    Failure scenarios to design for

    1. A Worker crashes mid-batch. Defined behaviour: the offset is committed only after the batch, the dedupe table skips applied events, and a recorded result is reused instead of calling the model again.
    2. Slow processing triggers a rebalance. A batch of 50 events handled one after another at 2 s each takes 100 s, inside the 300 s default max.poll.interval.ms; at 7 s each it takes 350 s and the consumer is treated as failed. Defined behaviour: keep batch size x worst-case time well below the interval, lower max.poll.records, and expect duplicates anyway.
    3. A poison message blocks a partition. Defined behaviour: a retry limit with backoff, a dead-letter queue with error context, per-key quarantine when order matters, and an alarm on dead-letter depth.
    4. The outbox relay publishes twice. Defined behaviour: consumers dedupe on event id, and an alert watches the age of the oldest unsent row.
    5. A saga compensation fails. Defined behaviour: compensations are idempotent and retried; after a set number of attempts the saga moves to a human queue with its full state.
    6. An approval never arrives. Defined behaviour: the request expires at its deadline, emits an expiry event and compensates anything already held.
    7. A consumer returns after its offset expired. With auto.offset.reset=latest it would skip silently. Defined behaviour: none or earliest for work you cannot lose, an alert on the exception, and dedupe for the replay.
    8. An orchestrator fans out too far. The research post reports early agents spawning 50 subagents for simple queries. Defined behaviour: effort rules by task type, a cap on subagents and a token budget per job.

    Worked example

    Size the Worker pool for the returns pipeline and see what a restart costs.

    Assumptions: illustrative figures. Peak load is 20 events per second. One agent step (model call plus tool call) takes 2.0 s. Each partition is processed one event at a time to keep per-key order. The topic has 48 partitions on 4 Worker instances (12 each). max.poll.records is 50 and the offset is committed after each batch. Events spread evenly. All Workers are down for 180 s.

    1. Concurrency needed. By Little's law (in-flight work = arrival rate x time in the system), 20 x 2.0 = 40 events are in flight, so at least 40 partitions must be busy.
    2. Capacity. 48 partitions at 2.0 s per event process 48 / 2.0 = 24 events per second; utilisation is 20 / 24 = 83%.
    3. Backlog after the outage. 20 x 180 = 3,600 events, 3,600 / 48 = 75 per partition. It drains at 24 - 20 = 4 per second, so 3,600 / 4 = 900 s, 15 minutes. With 40 partitions, capacity is 40 / 2.0 = 20 per second, equal to arrivals, and the backlog never drains.
    4. Restart duplicates. Each instance holds at most one uncommitted batch, so at most 4 x 50 = 200 events are redelivered. Without dedupe that is 200 x 2.0 = 400 worker-seconds and 200 extra model calls, about 400 / 48 = 8.3 s of whole-fleet time. With the check before the model call, repeats cost lookups.

    The design rule: capacity is partitions divided by step time, and 4 events per second of headroom turns a 3-minute outage into a 15-minute recovery.

    Code sample: an idempotent consumer that commits after processing

    This Java sketch uses the Kafka consumer API and a SQL database that supports ON CONFLICT DO NOTHING (PostgreSQL and SQLite do). It compiles against kafka-clients 3.9.0; the consumer is configured with enable.auto.commit=false.

    import java.nio.charset.StandardCharsets;
    import java.sql.*;
    import java.time.Duration;
    import java.util.List;
    import org.apache.kafka.clients.consumer.*;
    
    public class ClaimWorker {
        // consumer config: group.id=claim-workers, enable.auto.commit=false, String deserializers
        static void run(KafkaConsumer<String, String> consumer, Connection db) throws Exception {
            db.setAutoCommit(false);
            consumer.subscribe(List.of("claim-events"));
            while (true) {
                ConsumerRecords<String, String> batch = consumer.poll(Duration.ofSeconds(1));
                for (ConsumerRecord<String, String> r : batch) {
                    String eventId = new String(
                            r.headers().lastHeader("event-id").value(), StandardCharsets.UTF_8);
                    try (PreparedStatement seen = db.prepareStatement(
                            "INSERT INTO processed_events(event_id) VALUES (?) ON CONFLICT DO NOTHING")) {
                        seen.setString(1, eventId);
                        if (seen.executeUpdate() == 1) {   // first time this event id is seen
                            applyEffect(db, r);            // same transaction as the dedupe row
                        }
                        db.commit();                       // effect and dedupe row become durable together
                    } catch (Exception e) {
                        db.rollback();
                        throw e;                           // offset not committed: a restarted consumer gets the batch again
                    }
                }
                if (!batch.isEmpty()) consumer.commitSync(); // offsets move only after the whole batch
            }
        }
    
        static void applyEffect(Connection db, ConsumerRecord<String, String> r) throws SQLException {
            // write the triage result for r.key() using db
        }
    }

    Why it is correct. event_id must be the table's primary key or carry a unique constraint, or ON CONFLICT has nothing to conflict with and nothing is deduplicated. The dedupe row and the business write share one database transaction, so both exist or neither does. A crash before db.commit() leaves neither, and the redelivered event is applied. A crash after db.commit() but before commitSync() leaves both, and the redelivered event inserts nothing (executeUpdate() returns 0), so the effect is skipped. After an exception the redelivery happens when a new consumer starts from the committed offset; if the loop caught the exception and kept polling, the consumer's in-memory position would already be past the batch, so it would have to seek back first. commitSync() with no arguments commits the offsets returned by the last poll, correct here because every record of the batch was handled first. The sample assumes each record has an event-id header (a missing one would throw and redeliver the batch, so a real consumer should park it) and that applyEffect calls no external system inside the transaction. It leaves out retry limits, the dead-letter step and rebalance handling (a commitSync after the consumer has left the group can fail), and it must finish each batch within max.poll.interval.ms.

    Common mistakes

    • Treating "exactly-once" as a property of the whole system. It has a defined scope in the Kafka documentation; the side effect outside Kafka is yours to make idempotent.
    • Committing the offset before the work. It is the simplest way to lose events silently, and nothing alerts you.
    • Generating a new idempotency key on every retry. The server cannot recognise the repeat, so every attempt runs.
    • Publishing the event in a separate step after the database commit. A crash between them loses the event, and nobody notices until a customer asks.
    • Writing compensations that are not idempotent, or that cannot fail safely. The compensation path runs about 4 times in 100 in the worked example; it needs the same care as the forward path.
    • Adding a dead-letter queue and not watching it. It becomes a silent store of lost work, and of customer data.

    Try it

    Time: 30 to 40 minutes. Pick an asynchronous flow you know or invent a small one (a ticket-routing agent that posts to a chat tool).

    1. Draw its sequence diagram with an Event bus, one Worker restart and one human approval, using Figure 3 as the template.
    2. Write down the partition key and the ordering promise it gives.
    3. State your commit order and, for a batch size you choose, compute the duplicates one crash can cause and the events a commit-before-work design could lose.
    4. Define the event id, where the dedupe record lives and how long it is kept compared with the topic's retention.
    5. Pick two steps that can fail halfway and write their compensations, in reverse order, with idempotency keys.
    6. Define what happens to a message that fails five times, who is alerted and what order guarantee you keep.

    Done when every diagram has a figure title, an accessible title and description and a "how to read this" line, and you can point to the crash case, the duplicate case and the poison-message case in your design and name the defined behaviour for each.

    Key takeaways

    • More than one agent pays off only for valuable, parallelisable tasks that exceed one context; Anthropic reports about 15 times a chat's tokens (about 4 times for a single agent) and names shared-context and dependent tasks as a poor fit.
    • Kafka orders events per partition and gives each partition to one consumer in a group; the key is your ordering promise and the partition count your parallelism ceiling.
    • Delivery is at-least-once by default: commit after processing and make the effect idempotent. Exactly-once has a defined scope that stops at Kafka's edge.
    • An idempotency key comes from the event, is honoured where the effect happens and outlives the longest replay.
    • The outbox makes a state change and its event atomic, at the price of duplicate publishes that consumers absorb.
    • A saga replaces rollback with idempotent compensations in reverse order, and that path runs more often than intuition suggests.
    • A dead-letter queue works only if someone owns it, and parking a message breaks per-key order unless the key is quarantined.

    Self-check

    1. A teammate proposes splitting a coding task across five parallel agents that all edit the same repository. What do the published criteria say?

    Anthropic's multi-agent research post names domains where agents must share the same context, or have many dependencies, as a poor fit, and says coding has fewer parallelisable tasks than research. It also reports about 15 times the tokens of a chat for multi-agent systems, so the cost must be justified by value. Start with one agent and improve its tools.

    2. A Worker processes records 100 to 102 of a 5-record batch and crashes. What happens under commit-before-processing and commit-after-processing?

    Commit-before: the committed offset is 105, so records 103 and 104 are never processed (2 lost). Commit-after: the committed offset is still 100, so the replacement re-reads all five; records 100 to 102 are duplicates that an idempotent consumer skips, and 103 and 104 are processed. Kafka's documentation names these two orders at-most-once and at-least-once.

    3. Does Kafka's idempotent producer make a refund post exactly once?

    No. Idempotence applies to the producer's retries within a single producer session, so it keeps duplicates out of the Kafka log. It does not cover a consumer that reprocesses after a restart or an HTTP call to a payments system. Exactly-once for external systems generally needs the destination's cooperation, for example an idempotency key honoured there.

    4. Why use an outbox, and what does it not give you?

    It makes the database change and the message atomic: the message is stored in the same local transaction and a relay publishes it, so the message is sent if and only if the transaction commits. It does not give exactly-once publishing, because a relay that crashes between publishing and marking the row will publish again, so consumers must be idempotent.

    5. A four-step saga fails at step 3. Which compensations run, in what order, and what if one fails?

    The compensations for steps 2 and then 1 run, in reverse order (step 3 did not complete, so it has nothing to undo unless it partly completed). Each must be idempotent. If a compensation fails it is retried until it succeeds, and after a set number of attempts the saga escalates to a person with its state, because there is no further rollback.

    Further reading

    Last verified: 2026-10-22

    Next

    Continue to Evaluating AI systems: metrics, judges and gates, which shows how to test the agents and pipelines built here.

    Enroll to keep reading

    Lesson 7 of 13 · AI Architect: From AI Engineer to Expert

    When several agents beat one, and how to run them over events safely: partitions, offsets, idempotency keys, outbox, sagas and dead-letter queues.

    In the rest of this lesson

    • Under the hood: At-least-once and effectively-once processing
    • Under the hood: Idempotency keys
    • Under the hood: The outbox pattern
    • Under the hood: Sagas and compensating actions
    • Under the hood: Dead-letter queues and ordering
    • Decision framework

    Enrolling is free. It unlocks every lesson in AI Architect: From AI Engineer to Expert, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments