Distributed Message Queue Failures: Lost Acks, Duplicates & Recovery
Forty hypothetical failure scenarios on a fictional commerce platform, from lost acknowledgments and duplicate deliveries to hot partitions, full disks, leader loss and regional outages. Each is told as a trigger, a timeline, impact, cause, detection, containment, recovery, prevention, the guarantee that still holds and the tradeoff it costs. All numbers are teaching inputs, not measurements.
The Failure Map: Where an Event Can Be When Things Break
Forty ways a message platform goes wrong, from a lost acknowledgment to a missing region, each told as an incident with a clock, a cause, a recovery and the guarantee that survives. The pattern across all of them is that a message passes through several states, and every failure lives at a boundary between two of them.
Every scenario here is hypothetical. They use the fictional company HarborCart (commerce), order HC-2048 with event evt-HC2048-01 on topic orders partition 7 at offset 4,201, a shipment-jobs work queue, consumer groups email-notifier, sales-analytics and fraud-monitor, and a second tenant meridian. The numbers (20,000 events/s average, 100,000 events/s for a ten-minute promotion, 1 KiB events, 24 partitions, replication factor 3, three days of retention) are teaching inputs rather than measurements of any real system.
This page can be read on its own, so the vocabulary first. A producer publishes events; a consumer reads them. A queue hands each message to one worker; a topic delivers each message to every subscription, and a retained log keeps the messages for a while so they can be read again. A partition is one ordered slice of a topic, an offset is a position inside it, and a consumer group shares a topic's partitions among its members so that each partition has one active reader. A leader replica accepts writes for a partition and followers copy it; the in-sync replicas (ISR) are the followers that are keeping up. A lease or visibility timeout hides a claimed job from other workers for a while, and an acknowledgment or offset commit tells the broker the consumer is done. Idempotency means repeating an operation has the same effect as doing it once; a DLQ parks messages that cannot be processed.
Where an event can be, and what each state proves
A published event moves through distinct states: the producer issued it, the broker received it, the leader appended it, the configured replicas hold it, the producer received an acknowledgment, the consumer was handed it, the business effect committed, and the consumer's acknowledgment or offset commit was recorded. These are never the same fact. When the broker acknowledges a publish, it says nothing about whether a consumer has done anything; when a consumer commits an offset, it says nothing about whether a payment was captured unless the code orders them correctly. Most incidents below are the result of treating two adjacent states as one.
What no design on this page can promise
Some guarantees are unavailable at any price, and the incidents keep returning to them: a single global order across a partitioned topic, an effect applied exactly once across systems that share no transaction, replay beyond the retention window, automatic cross-region failover for data that residency rules keep in place, and continued writes after losing more replicas than the policy tolerates. With replication factor 3 and a minimum of two in-sync replicas, one broker can fail and writes continue; two cannot, and writes are refused rather than risk silent loss.
How each incident is written
Each incident starts with the story and the arithmetic where numbers help, then closes with a table listing trigger, timeline, impact, root cause, detection, containment, recovery, prevention, the guarantee that still holds and the tradeoff it costs, plus the invariant (I1 to I12) it touches. The invariants are the ones used across this module: identity comes from the verified principal (I1), a durable acknowledgment follows the advertised persistence (I2), publish, replicate, deliver and effect are separate facts (I3), ordering holds only within a key's partition (I4), there is no free exactly-once (I5), retries are bounded and poison is quarantined (I6), everything is bounded (I7), committed data survives only the failures the policy covers (I8), replay is bounded by retention and authorization (I9), outbox atomicity is local to its database (I10), personal data is minimized (I11) and the key signals are visible (I12).
The map
| ID | Incident (hypothetical) | Category | Chapter |
|---|---|---|---|
| F01 | Synchronous email timeout fails checkout | Sync coupling | From Synchronous Calls to a Durable Queue |
| F02 | Process crashes with queued work in RAM | Durability | From Synchronous Calls to a Durable Queue |
| F03 | Producer write succeeds but the acknowledgement is lost | Publish ambiguity | From Synchronous Calls to a Durable Queue |
| F04 | Broker acknowledges a memory-only write, then crashes | Durability | From Synchronous Calls to a Durable Queue |
| F05 | Leader fails after replication but before the ack | Leader failover | Replication and Leader Failures |
| F06 | Leader fails before the record reaches the in-sync replicas | Leader failover | Replication and Leader Failures |
| F07 | In-sync replica set shrinks below the required minimum | Availability | Replication and Leader Failures |
| F08 | Leader elected from an out-of-date replica | Unsafe election | Replication and Leader Failures |
| F09 | Publisher retry duplicates an event | Duplicates | Publisher Retries, Ordering and the Outbox |
| F10 | Retries reorder events within a partition | Ordering | Publisher Retries, Ordering and the Outbox |
| F11 | Consumer applies the side effect, then crashes before committing the offset | Commit order | Consumer Commits, Leases and Rebalances |
| F12 | Worker acknowledges the job before the business transaction commits | Commit order | Consumer Commits, Leases and Rebalances |
| F13 | Work lease expires while a long job is still running | Leases | Consumer Commits, Leases and Rebalances |
| F14 | Rolling deploy rebalances the group while batches are in flight | Rebalance | Consumer Commits, Leases and Rebalances |
| F15 | Orphaned consumer with a stale membership epoch writes anyway | Fencing | Consumer Commits, Leases and Rebalances |
| F16 | Poison event blocks a partition | Poison | Poison Events, Retries, DLQs and Schemas |
| F17 | Infinite immediate retry creates a hot loop | Retry | Poison Events, Retries, DLQs and Schemas |
| F18 | The dead-letter queue grows silently | DLQ | Poison Events, Retries, DLQs and Schemas |
| F19 | DLQ redrive repeats a payment capture | DLQ | Poison Events, Retries, DLQs and Schemas |
| F20 | A schema change breaks an older consumer | Schema | Poison Events, Retries, DLQs and Schemas |
| F21 | High-volume customer creates a hot partition | Hot partition | Load, Partitions and Capacity |
| F22 | Slow subscriber's backlog grows while other groups are healthy | Lag | Load, Partitions and Capacity |
| F23 | Retention deletes messages before a lagging group catches up | Retention | Retention, Replay and Compaction |
| F24 | Broker disk fills during a promotion burst | Disk | Load, Partitions and Capacity |
| F25 | Reconnect storm saturates the metadata and control plane | Control plane | Load, Partitions and Capacity |
| F26 | Partition expansion changes key placement and ordering scope | Partitioning | Load, Partitions and Capacity |
| F27 | Order committed, event missing: the dual-write gap | Outbox | Publisher Retries, Ordering and the Outbox |
| F28 | Outbox relay publishes, then crashes before marking rows dispatched | Outbox | Publisher Retries, Ordering and the Outbox |
| F29 | Clock skew misreports lag | Metrics | Observability Failures |
| F30 | Cross-tenant subscription attempt | Security | Tenancy, Payloads and Data Protection |
| F31 | A huge or hostile payload overwhelms the validator | Abuse | Tenancy, Payloads and Data Protection |
| F32 | An unauthorized retention override exposes customer data | Data protection | Tenancy, Payloads and Data Protection |
| F33 | A region fails while residency policy forbids mirroring | Regions | Regional Failures and Asynchronous Mirrors |
| F34 | Async mirror falls behind, then is promoted with a missing suffix | Regions | Regional Failures and Asynchronous Mirrors |
| F35 | Compaction removes history a consumer assumed was there | Compaction | Retention, Replay and Compaction |
| F36 | A consumer starts from an offset outside retention | Retention | Retention, Replay and Compaction |
| F37 | The metrics pipeline depends on the broken cluster | Observability | Observability Failures |
| F38 | A rolling upgrade changes protocol or schema compatibility | Compatibility | Poison Events, Retries, DLQs and Schemas |
| F39 | A few extremely heavy keys unbalance partitions | Hot key | Load, Partitions and Capacity |
| F40 | Scaling the group beyond the partition count changes nothing | Scaling | Load, Partitions and Capacity |
From Synchronous Calls to a Durable Queue
The first four incidents follow the order in which HarborCart's design changes: a synchronous checkout that fails because of an optional dependency, a memory-only queue that forgets, a publish whose outcome nobody knows, and an acknowledgment that promises more than the broker has done.
F01 — Synchronous email timeout fails checkout
In HarborCart's original design the checkout handler calls payment, inventory, email and analytics one after another and returns only when all four answer. During a promotion the email provider starts answering in about 8 s instead of the usual few hundred milliseconds. Payment has already captured the money for HC-2048 by then, so the customer sees an error page for an order that was in fact paid.
The arithmetic explains why a slow dependency becomes an outage. Say checkout receives 100 orders/s (a hypothetical promotion rate) and each request now holds a thread for 8 s: Little's Law gives 100 × 8 = 800 requests in flight, against a pool of 200 threads. The pool is full within about two seconds, new requests queue behind it, and the payment and inventory calls that were healthy start timing out purely from waiting. Customers retry, which adds load and risks a second charge unless payment is idempotent. Nothing about email was ever important enough to block a purchase; the chain simply made it so.
Moving email and analytics behind a queue or topic removes them from the request path, but it does not remove the failure; it relocates it. The email consumer can now fall behind quietly, which is why lag and oldest-message age become the signals to watch.
| Facet | F01 at a glance |
|---|---|
| Trigger | Email provider latency rises from sub-second to about 8 s while checkout runs at a hypothetical 100 orders/s. |
| Timeline | 1) t=0 s email latency climbs to 8 s. 2) t=2 s the 200-thread pool saturates (800 needed). 3) t=5 s payment and inventory calls time out while waiting for threads. 4) t=30 s customers retry; error rate dominates the dashboard. |
| Impact | Paid orders show failures; duplicate payment attempts; revenue loss on a path that did not need email to complete. |
| Root cause | Synchronous coupling: the slowest optional dependency sets the latency and availability of the whole checkout. |
| Detection | Thread-pool saturation, p99 checkout latency, per-dependency timeout counts; spans showing the email call dominating the trace. |
| Containment | Skip or defer email and analytics with a feature flag; shed non-essential callers; keep payment calls idempotent. |
| Recovery | Reconcile orders that were paid but reported failed; send the emails that were skipped from the order table. |
| Prevention | Publish an event after the order commits and let email, analytics and fraud subscribe asynchronously; keep only payment and inventory reservation inline. |
| Guarantee that remains | The order and payment still commit atomically with respect to checkout; email delivery becomes eventual and independently retried. |
| Tradeoff | Asynchrony buys isolation and costs eventual consistency: the confirmation email may trail the order by seconds or minutes, and there is now a backlog to monitor. |
| Invariant | I7 (everything bounded), I12 (visible lag). |
F02 — Process crashes with queued work in RAM
The first fix HarborCart tried for F01 was an in-process queue: checkout drops a shipment task into a list in memory and a background thread drains it. That is fast and simple, and it is gone the instant the process is. A node is replaced during a routine deploy while 3,000 shipment tasks are waiting.
At a hypothetical working rate of 83.33 tasks/s (300,000 an hour), 3,000 tasks is about 36 s of backlog. Paid orders exist in the database; the shipment jobs that should follow them do not. Nobody receives an error, because the failure is an absence: no exception, no retry, no log line, and the customers are simply not shipped. Discovery comes from support tickets days later unless a reconciliation job compares paid orders to shipments.
A durable queue changes who owns the task. Once the job is stored outside the process, a crash only interrupts a worker, and the leased task becomes available again after its timeout. The remaining question is whether the producer knew the task was stored, which is what F03 and F04 are about.
| Facet | F02 at a glance |
|---|---|
| Trigger | Rolling deploy kills a pod holding 3,000 unprocessed in-memory shipment tasks. |
| Timeline | 1) Checkout enqueues tasks into process memory and replies 200. 2) Deploy sends SIGTERM; the drain loop stops with 3,000 tasks left. 3) Pod restarts with an empty queue. 4) Orders stay 'paid' with no shipment record. |
| Impact | About 36 s of shipping work silently lost (3,000 ÷ 83.33); customers wait with no signal. |
| Root cause | The only copy of the work lived in volatile memory with no acknowledgement boundary. |
| Detection | Paid-but-unshipped age on a reconciliation query; enqueue count minus processed count diverging; queue depth dropping to zero without work completing. |
| Containment | Pause deploys; run the reconciliation query to find orders missing shipment rows. |
| Recovery | Re-create jobs for every paid order lacking a shipment, keyed by order id so reruns are harmless. |
| Prevention | Store jobs in a persistent queue (or write them through the outbox) and acknowledge only after durable storage; make shipment creation idempotent. |
| Guarantee that remains | Once the job is durably stored, it is delivered at least once until a worker acknowledges it. |
| Tradeoff | A durable queue adds a network hop and a disk write per task, plus a lease or visibility timeout to reason about. |
| Invariant | I2, I3. |
F03 — Producer write succeeds but the acknowledgement is lost
The order service publishes OrderPlaced (event id evt-HC2048-01) to orders. The leader appends it at partition 7, offset 4,201, replicates it, and sends the acknowledgement; the TCP connection resets and the response never reaches the producer. From the producer's side there is only a timeout, and a timeout is a statement about its own knowledge, not about the broker's state.
There are exactly two worlds behind that timeout. In one the record never arrived and no event exists; in the other it is committed and the answer was lost. The producer cannot tell them apart by waiting longer. If it gives up, the order may have no event (the worse case for downstream consumers); if it retries blindly, the log may contain two copies. Within a single producer session an idempotent producer (a client feature that attaches sequence numbers per partition so the broker can drop a repeated batch) turns the retry into a no-op, but only while that session lives.
The robust answer is to treat the outcome as unknown and make both outcomes safe: keep the event in the outbox until a confirmed acknowledgement, resend with the same event id, and let consumers deduplicate on (consumer, event_id).
| Facet | F03 at a glance |
|---|---|
| Trigger | TCP connection resets after the leader appended offset 4,201 and before the ack reached the order service. |
| Timeline | 1) t=0 ms producer sends batch with evt-HC2048-01. 2) t=12 ms leader appends and replicas confirm. 3) t=15 ms ack is sent and dropped by a reset connection. 4) t=15 s producer times out, outcome unknown. 5) t=15 s retry carries the same id; the broker or the consumers recognize the duplicate. |
| Impact | Either a missing event or a duplicate, depending on how the producer resolves its doubt. |
| Root cause | A publish has three outcomes (confirmed, rejected, unknown) and the unknown case is irreducible over an unreliable network. |
| Detection | Publish-unknown counter; outbox rows older than a threshold without a confirmation; log-end offsets compared with relay sent counts. |
| Containment | Do not mark the outbox row dispatched; retry with the same event id. |
| Recovery | Reconcile by event id; consumers absorb the duplicate through their inbox. |
| Prevention | Idempotent producer within a session, stable event ids across sessions, outbox until confirmed, consumer dedupe. Broker-side dedupe has bounded memory: an SQS FIFO queue, for example, deduplicates sends only within a five-minute interval, so a retry after a longer outage is a new message. |
| Guarantee that remains | An acknowledged publish is committed per policy; an unacknowledged one may or may not exist, and the design is correct in both cases. |
| Tradeoff | At-least-once publishing accepts duplicates in exchange for never silently dropping an acknowledged-by-business event. |
| Invariant | I3, I5. |
F04 — Broker acknowledges a memory-only write, then crashes
During a throughput experiment someone sets the shipment-jobs queue to acknowledge as soon as the broker has received the message, with no replication wait and no flush. Publish latency improves visibly. A node crash then reveals what the acknowledgement actually meant.
At 20,000 events/s, a 50 ms window of unflushed data holds 20,000 × 0.05 = 1,000 events (50 ms is a hypothetical flush interval). The broker answered 'got it' for all of them, the producers moved on and marked their outbox rows dispatched, and the crash erased the buffer. Because the producers saw success, nothing retries and nothing alerts. Only a later count of expected versus consumed events shows the gap.
The mistake is not that memory is fast; it is that the acknowledgement claimed more than had happened. A broker acknowledgment can mean any of four different things: received, appended to the log, replicated, or flushed to disk, and a sound design makes each ack name the step it covers. HarborCart's canonical setting waits for the in-sync replicas, so an acknowledged event has already survived a single-broker crash.
| Facet | F04 at a glance |
|---|---|
| Trigger | Ack-on-receive configuration plus a broker crash 30 ms after a batch of acknowledged writes. |
| Timeline | 1) Producer sends a batch; the broker buffers it and acks at once. 2) Outbox rows are marked dispatched. 3) The broker process dies before flush and replication. 4) On restart the log ends before the lost batch; nothing triggers a resend. |
| Impact | Roughly 1,000 acknowledged events vanish per 50 ms of exposure at the average rate; downstream state silently diverges. |
| Root cause | Ack semantics weaker than the producer's assumption; the broker reported receipt as if it were commitment. |
| Detection | End-to-end counters (produced vs consumed) per topic; broker restart events correlated with offset gaps; config audit for non-default ack levels. |
| Containment | Restore the replicated ack setting; stop marking outbox rows dispatched on weak acks. |
| Recovery | Replay the affected window from the order outbox or database using event ids; consumers deduplicate. |
| Prevention | Treat durability settings as reviewed production config; wait for the in-sync replicas before acknowledging; test by killing brokers under load. |
| Guarantee that remains | With the canonical setting, an acknowledged record is on at least two replicas before the producer is told. |
| Tradeoff | Waiting for replication costs publish latency (an illustrative figure of about 15 ms) and some peak throughput. |
| Invariant | I2, I3. |
Replication and Leader Failures
A replicated log is only as safe as the rule that says when a write is committed and which replica may lead after a failure. These four incidents sit on the boundary between acknowledged, committed and available.
F05 — Leader fails after replication but before the ack
Broker L leads partition 7 at leader epoch 7. A producer sends OrderPlaced for HC-2048; L appends it at offset 4,201, both followers copy it, and the record is now committed, meaning the in-sync replicas all hold it. L then loses power before it can answer. The controller elects F1, epoch 8, and the record survives on the new leader.
The producer experiences the same silence as in F03 and cannot know any of this. When it retries to the new leader, one of two things happens. If the retry comes from the same idempotent producer session and the sequence state is recoverable from the replicated log, the new leader recognizes the batch and does not append it again. If the retry comes from a freshly started producer, or the relay marked nothing and a new process picks up the row, the log gains a second copy at a later offset. Both outcomes are consistent with at-least-once publishing.
This scenario matters because it shows the commit point and the acknowledgement are different moments. Replication being complete protects the data; the ack only informs the sender. Durability and knowledge can part ways for a few hundred milliseconds, and the design must tolerate that gap.
| Facet | F05 at a glance |
|---|---|
| Trigger | Leader L loses power between full replication of offset 4,201 and sending the acknowledgement. |
| Timeline | 1) L appends offset 4,201 (epoch 7). 2) F1 and F2 copy it; the record is committed. 3) L dies before replying. 4) Controller elects F1; epoch becomes 8. 5) Producer times out and retries against F1. |
| Impact | No data loss; a possible duplicate and a few seconds of publish latency or timeout for partition 7. |
| Root cause | The acknowledgement is sent after commit, so a crash in between separates durability from the sender's knowledge. |
| Detection | Leader-change events; publish retry spikes; epoch increment on partition 7; duplicate event ids at the consumer inbox. |
| Containment | Let the election finish; do not force a second election or restart brokers. |
| Recovery | Verify end offsets agree across replicas; absorb duplicate event ids through the inbox. |
| Prevention | Same event id on retry; idempotent producer; inbox dedupe; timeouts longer than a normal election. |
| Guarantee that remains | Committed records survive the single failure, because the replica set had at least two in-sync copies. |
| Tradeoff | A longer client timeout tolerates elections but delays detection of real outages. |
| Invariant | I2, I3, I8. |
F06 — Leader fails before the record reaches the in-sync replicas
Now invert F05. L appends the record at offset 4,201 to its own log, but the followers are still at 4,200 when L crashes. Because the producer asked to wait for the in-sync replicas, no acknowledgement was sent, so the producer never believed it was stored.
F1 becomes leader with its log ending at 4,200. The next event published to partition 7 now receives offset 4,201 on F1's log, which is a different record from the one L wrote. When L restarts it discovers that its tail diverges from the leader of epoch 8 and truncates its extra record before following. The lost record was never acknowledged, so the contract holds, but only if the producer retries. A fire-and-forget producer would have lost an order event without anyone noticing.
Truncation of an uncommitted tail is the mechanism that keeps replicas identical, and it is the reason consumers must read only up to the committed point (the high watermark). A consumer that could see offset 4,201 on L before commit would have read a record that later disappears.
| Facet | F06 at a glance |
|---|---|
| Trigger | Leader crashes with a locally appended offset 4,201 that no follower has fetched. |
| Timeline | 1) L appends offset 4,201; followers lag at 4,200. 2) L crashes; producer receives no ack. 3) F1 is elected (epoch 8) at log end 4,200. 4) Producer retries; the record is committed at a fresh 4,201 on F1. 5) Old L rejoins and truncates its divergent tail. |
| Impact | One publish needs a retry; with a careless producer, one event is lost without ever being acknowledged. |
| Root cause | Writes become durable only when the in-sync replicas hold them; before that point a leader's tail is provisional. |
| Detection | Leader change with uncommitted tail truncation logs; producer error counters; log-end-offset mismatch alarms. |
| Containment | Producers retry on timeouts; hold consumers to the committed point. |
| Recovery | Rely on retry from outbox or producer buffers; confirm event id presence after recovery. |
| Prevention | Acknowledge only after the in-sync replicas hold the record; never treat a leader-local append as acceptance. |
| Guarantee that remains | Every acknowledged record was on at least two replicas, so a single failure cannot erase it. |
| Tradeoff | Waiting for followers adds latency to every publish; a failure can still turn into producer errors that someone must handle. |
| Invariant | I2, I8. |
F07 — In-sync replica set shrinks below the required minimum
orders is configured with replication factor 3 and a minimum of 2 in-sync replicas (the leader counts toward that minimum). A network problem makes F1 and F2 miss their fetch deadlines, and the leader removes them from the in-sync set. The in-sync set is now just L, one replica, below the minimum of two.
With acknowledgement on in-sync replicas, the leader refuses writes (the Kafka-style error is NotEnoughReplicas) rather than acknowledge something it cannot protect. Data already committed is still there and still readable; what has been lost is the ability to accept new writes on those partitions. If the producers retry for the default delivery timeout of 120 s, the order service keeps committing orders and the outbox grows: 20,000 events/s × 120 s = 2,400,000 rows in two minutes, a number worth having in mind before the relay is asked to catch up.
This is the boundary of the guarantee, not a bug. Replication factor 3 with a minimum of 2 survives one broker loss with writes continuing. Two losses leave committed data on the survivor but stop writes. The alternative, accepting writes on a single replica, would turn the next failure into data loss.
| Facet | F07 at a glance |
|---|---|
| Trigger | Two followers fall out of sync after a network partition, leaving an in-sync set of one. |
| Timeline | 1) F1 and F2 stop fetching on time. 2) Leader shrinks the in-sync set to {L}. 3) Producers receive NotEnoughReplicas. 4) Outbox rows accumulate at about 20,000 per second. 5) Network heals; followers catch up and rejoin; writes resume. |
| Impact | Writes to affected partitions are rejected; reads of committed data continue; roughly 2.4 million events wait in the outbox after two minutes. |
| Root cause | The write policy requires two in-sync replicas and only one remained. |
| Detection | Under-min-ISR partition count; ISR shrink events; producer error rate; outbox row count and oldest unsent age. |
| Containment | Keep rejecting writes; fix the network or the follower; expand relay capacity only after writes work. |
| Recovery | Followers rejoin after catching up; the relay drains the outbox with event ids intact. |
| Prevention | Capacity for followers, network redundancy, alerts on ISR shrink before it reaches the minimum. |
| Guarantee that remains | Committed data remains on the surviving replica; no acknowledged write was accepted under the minimum. |
| Tradeoff | Rejecting writes costs availability to protect durability; the choice to flip it (minimum of 1) trades loss risk for uptime. |
| Invariant | I7, I8. |
F08 — Leader elected from an out-of-date replica
Unclean leader election is disabled on orders. In the middle of an incident, with L and F1 both down, an operator sees partition 7 offline and enables it temporarily so that service resumes. F2 had been lagging by 1,200 records, so it becomes leader with a log that ends 1,200 offsets earlier than the committed end.
Writes resume at once, which looks like success. Every one of those 1,200 records had been acknowledged to producers, and they now exist only on the offline brokers. When those brokers return they must truncate to follow the new leader, so the records are erased. Consumers whose committed offsets sit beyond the new end find themselves holding positions that no longer exist; depending on policy they reset to earliest or latest, and either one can skip or reprocess data.
This is the cleanest example of availability bought with durability. The setting is not wrong in every context (a metrics topic may accept it), but for orders the cost is invisible loss of acknowledged events. The recoverable path is to bring a complete replica back, even if it takes minutes longer.
| Facet | F08 at a glance |
|---|---|
| Trigger | Operator enables unclean election during a double broker outage; the elected replica is 1,200 records behind. |
| Timeline | 1) L and F1 fail; partition 7 has no in-sync leader. 2) Operator enables unclean election. 3) F2 becomes leader (epoch 8) with a shorter log. 4) Writes resume; L and F1 return and truncate 1,200 records. 5) Consumers see offsets out of range. |
| Impact | 1,200 acknowledged records are lost; consumers reset or fail; order state diverges from the event stream. |
| Root cause | Election preferred availability over the committed suffix. |
| Detection | Unclean election log lines and metric; offset out-of-range errors; consumer resets; produced versus consumed audits. |
| Containment | Prefer waiting for a full replica; revert the override immediately after the election. |
| Recovery | Rebuild the lost window from the order database and outbox, replaying events by id; consumers deduplicate. |
| Prevention | Keep unclean election off for business topics; add replicas or racks so a double failure is rarer; require review for overrides. |
| Guarantee that remains | With the setting off, an elected leader always holds the committed suffix. |
| Tradeoff | Safety may mean an offline partition until a complete replica returns. |
| Invariant | I8. |
Publisher Retries, Ordering and the Outbox
Retrying a publish is how producers survive a broken network, and it is also how duplicates and reordering get in. The outbox solves a different gap, between the order database and the broker, and has its own duplicate.
F09 — Publisher retry duplicates an event
HarborCart's outbox relay reads evt-HC2048-01, publishes it, and the batch is appended at offset 4,201. The relay's call times out locally, the relay process exits on a health-check failure, and the replacement starts a new producer. It reads the same outbox row, still not marked, and publishes again; the log now holds the event at 4,201 and again at some later offset.
The broker did exactly what it was asked. An idempotent producer protects a single session (a producer id plus its epoch, with per-partition sequence numbers), so a new process is a stranger to the broker. Application-level resends with new identifiers are also invisible to it. The protection belongs to the layer that knows the business identity of the event: the consumer's inbox keyed by (consumer, event_id).
A common reaction is to chase an exactly-once publisher. Transactions in a log can make read-process-write cycles atomic inside the log, yet they do not extend to a payment API or a mail provider. The honest design accepts duplicates and removes their effect.
| Facet | F09 at a glance |
|---|---|
| Trigger | Relay timeout and process replacement cause the same outbox row to be published by two producer sessions. |
| Timeline | 1) Relay publishes evt-HC2048-01; broker appends it. 2) Local timeout fires and the process is replaced. 3) New relay session finds the row unmarked. 4) Event is appended a second time. |
| Impact | Two log copies of one business event; duplicate emails or analytics rows unless consumers deduplicate. |
| Root cause | Producer idempotence is scoped to one session; identity across sessions has to come from the application. |
| Detection | Duplicate event-id counts at the inbox; relay restart count; outbox rows with more than one publish attempt. |
| Containment | Let the inbox reject duplicates; avoid manual republishing. |
| Recovery | No repair needed if the inbox exists; otherwise correct downstream aggregates by event id. |
| Prevention | Stable event ids, an inbox table, idempotent side effects. |
| Guarantee that remains | Each consumer applies the event at most once as long as the inbox write and effect share a transaction. |
| Tradeoff | The inbox costs a lookup and storage per event and a retention policy for dedupe keys. |
| Invariant | I5. |
F10 — Retries reorder events within a partition
The order service sends OrderPlaced and OrderPaid for HC-2048 with key order:HC-2048, so both go to partition 7. The producer allows several batches in flight and has idempotence turned off. The batch holding OrderPlaced fails with a transient error, the one holding OrderPaid succeeds at offset 4,201, and the retried OrderPlaced lands at 4,202.
The ordering promise was only ever about append order, and the append order has been changed by the retry. A consumer sees a payment for an order it has not yet seen. Depending on code it may drop the update, create a stub order, or crash into the retry path. Strict ordering is a property of the whole chain: key choice, in-flight limit, idempotence, and consumer logic.
With idempotence on, a Kafka-style producer can keep up to five requests in flight and still preserve order per partition, because the sequence numbers let the broker reject out-of-order retries. Setting a single in-flight request also works but caps throughput. Ordering across partitions is simply not promised.
| Facet | F10 at a glance |
|---|---|
| Trigger | Idempotence disabled with several in-flight batches; a transient failure on the first batch. |
| Timeline | 1) Producer sends batch A (OrderPlaced) and batch B (OrderPaid). 2) A fails transiently; B succeeds at offset 4,201. 3) A is retried and appended at 4,202. 4) Consumer reads OrderPaid before OrderPlaced. |
| Impact | State machine violations: payment before placement; consumer errors and rework. |
| Root cause | Retry plus multiple in-flight batches without sequence checks changes append order. |
| Detection | Per-key sequence checks at consumers; out-of-order transition counters; producer retry rates. |
| Containment | Route out-of-order events to a parking path with a short wait for the missing predecessor. |
| Recovery | Reprocess in the correct order after the predecessor arrives; correct state by version number. |
| Prevention | Enable idempotence with the in-flight limit it requires; use per-key version numbers; design consumers to tolerate a short reorder window. |
| Guarantee that remains | Within one partition, with idempotence, order of acknowledged appends is preserved. |
| Tradeoff | Low in-flight limits protect order and give up peak throughput; consumer-side buffering costs state and delay. |
| Invariant | I4. |
F27 — Order committed, event missing: the dual-write gap
The order service writes the order row for HC-2048 to its database, then publishes OrderPlaced to the broker, as two independent steps. The database commit succeeds and the process crashes before the publish. The customer sees a confirmed order; email, fraud and analytics never hear of it.
A database transaction and a broker publish cannot be made atomic by ordering the calls. If you publish first and the commit fails, you broadcast an order that does not exist; if you commit first and the publish fails, you lose the event. Retrying inside the same process helps only until the process dies, which is exactly when it matters.
The transactional outbox removes the second write: the order row and an outbox row commit in one local transaction, and a relay publishes the outbox row later, marking it dispatched on a confirmed acknowledgement. The state of affairs after a crash is recoverable because the intent is stored. This atomicity is local to the originating database; it does not make the downstream consumers' effects atomic with anything.
| Facet | F27 at a glance |
|---|---|
| Trigger | Process crash between database commit and broker publish. |
| Timeline | 1) Order HC-2048 commits. 2) Process crashes before publish. 3) Customer sees a confirmation. 4) No OrderPlaced event is ever produced. |
| Impact | Orders with no email, no fraud screening, no analytics; silent inconsistency. |
| Root cause | Two systems written separately cannot be committed atomically. |
| Detection | Orders without matching events (reconciliation by id); outbox lag; business KPI gaps. |
| Containment | Run a reconciliation job to find unpublished orders. |
| Recovery | Publish missing events from the order table with their stable ids. |
| Prevention | Transactional outbox with an at-least-once relay and idempotent consumers; see the outbox pattern. |
| Guarantee that remains | If the order row exists, the outbox row exists; the event will be published at least once. |
| Tradeoff | An outbox adds a table, a relay and publish latency, and requires consumer dedupe. |
| Invariant | I10, I5. |
F28 — Outbox relay publishes, then crashes before marking rows dispatched
The relay reads 500 undispatched outbox rows, publishes them as a batch, and is about to update their status when it is killed. The broker holds all 500 events. The database still lists them as pending.
On restart the relay selects the same 500 rows and publishes them again. The log now has 500 duplicates, adjacent to the originals or later depending on timing. Per-key order is still intact for the first copy of each event; the second copies arrive after newer events, so a consumer that treats arrival order as truth sees an old status after a new one. The relay is at-least-once by construction: the only way to make it at-most-once is to risk F27's loss.
Consumers protect themselves with the inbox and, for state, version numbers (apply an event only if its version is newer than the stored one). Smaller relay batches reduce the number of duplicates after a crash at the cost of more database round trips; neither choice removes them.
| Facet | F28 at a glance |
|---|---|
| Trigger | Relay killed after a successful publish of 500 rows and before the dispatched update. |
| Timeline | 1) Relay selects 500 pending rows. 2) Broker acknowledges the batch. 3) Relay dies before marking them. 4) Restarted relay publishes the same 500 again. |
| Impact | Up to 500 duplicate events per crash, with possible stale-after-new ordering at consumers. |
| Root cause | Publish and mark are two steps; crash between them repeats the publish. |
| Detection | Duplicate event id rate at inboxes; relay restarts; relay batch retry counts. |
| Containment | Do not manually mark rows; let consumers deduplicate. |
| Recovery | No repair needed with idempotent consumers; otherwise correct aggregates by event id. |
| Prevention | Inbox, version checks, stable ids, relay batch sizing. |
| Guarantee that remains | Every committed order's event is published at least once. |
| Tradeoff | Smaller batches reduce the duplicate window and add database load. |
| Invariant | I5, I10. |
Consumer Commits, Leases and Rebalances
Consumers are where a delivery becomes a business effect. These incidents are about the order of the effect, the acknowledgment and the ownership claim, and what happens when ownership changes under a running worker.
F11 — Consumer applies the side effect, then crashes before committing the offset
The sales-analytics group reads OrderPlaced for HC-2048 from partition 7, offset 4,201. It inserts a revenue row into the analytics database, and the process is killed by the OOM killer before it commits offset 4,201. The committed offset, which is the group's bookmark of work finished, still says 4,200. (Kafka actually stores the next offset to read, so its bookmark would read 4,201 here; this page writes the last finished offset to keep the story easy to follow.)
When the partition is reassigned, the new owner resumes at the bookmark and reads 4,201 again. A second revenue row appears. Nothing was malfunctioning: the group committed after the side effect, which is the safe order, because the opposite order loses data (F12). It means redelivery is an expected event and a consumer that is not ready for it double-counts. Revenue dashboards drift upward by one order at a time, which is a hard error to notice by eye.
The repair belongs inside the database transaction. The consumer writes the business row and an inbox row (sales-analytics, evt-HC2048-01) together; a replay hits the unique key and is ignored. The offset commit then merely records progress, and the inbox is what carries correctness. If the effect is not in a database the consumer controls (an email, say), the effect itself needs an idempotency key supported by the provider.
| Facet | F11 at a glance |
|---|---|
| Trigger | Process killed between the analytics insert for offset 4,201 and the offset commit. |
| Timeline | 1) Consumer polls offset 4,201. 2) Insert into the analytics table commits. 3) Process dies before the offset commit. 4) New owner resumes from committed offset 4,200. 5) Event 4,201 is delivered again. |
| Impact | One duplicate revenue row per crash per in-flight event; totals drift. |
| Root cause | The side effect and the offset commit are two separate writes; no crash-safe ordering makes both atomic. |
| Detection | Duplicate event-id counts; reconciliation of analytics revenue against the order database; redelivery rate after restarts. |
| Containment | Pause the consumer if the duplicate rate is high; do not rewind blindly. |
| Recovery | Delete duplicate rows by event id; add the inbox then replay. |
| Prevention | Commit after the effect; write an inbox row in the same transaction as the effect; use unique keys. |
| Guarantee that remains | With the inbox, each event's effect is applied once per consumer even though delivery is at-least-once. |
| Tradeoff | An inbox table adds a write per event and retention work for old ids. |
| Invariant | I5, I3. |
F12 — Worker acknowledges the job before the business transaction commits
Worker A claims ship-HC-2048 from the shipment-jobs queue. To keep the queue clear, its code acknowledges the message as soon as it is received and then starts creating the shipment row in the order database. The database connection pool is exhausted, the transaction fails, and the process exits.
The queue believes the work is done: the ack told it to delete the message. There is no lease to expire and no retry to trigger. The task has vanished while the order remains paid and unshipped. This is the mirror image of F11, and unlike it nothing will ever repair itself. A DLQ cannot catch it either, since the message was never negatively acknowledged. RabbitMQ's own documentation describes the same trap for automatic acknowledgement mode, where a delivery counts as done as soon as it is sent, so a consumer whose connection or channel closes first loses the message.
The rule for an acknowledgement is that it asserts the work is finished or safely handed to something durable. Commit the transaction first, then ack; accept that a crash in the gap yields a redelivery, and make the work idempotent by job id (the shipment table's unique key on ship-HC-2048).
| Facet | F12 at a glance |
|---|---|
| Trigger | Worker acks on receipt; the database transaction later fails and the process exits. |
| Timeline | 1) Worker A leases ship-HC-2048. 2) Worker A acks immediately. 3) Database pool exhausted; transaction fails. 4) Process exits; the job no longer exists in the queue. |
| Impact | A paid order is never shipped; no alert fires because the queue shows empty and healthy. |
| Root cause | Acknowledgement was sent before the effect, so the broker's record of completion is false. |
| Detection | Paid-but-unshipped age; enqueued minus effects-committed counters; ack-before-commit found in a code review or a trace. |
| Containment | Fix the worker to ack after commit; find orders without shipment rows. |
| Recovery | Recreate the missing jobs from the order table with job ids, relying on the unique key. |
| Prevention | Ack after the transaction commits; lease timeouts; a reconciliation job as a safety net. |
| Guarantee that remains | If a worker ack follows a committed effect, the job cannot be lost; a crash before the ack causes redelivery. |
| Tradeoff | Ack-after-commit trades loss for possible duplicate work, which the shipment key absorbs. |
| Invariant | I3, I5. |
F13 — Work lease expires while a long job is still running
A work queue hides a claimed message from other workers for a visibility timeout (also called a lease): HarborCart uses 60 s as an illustrative value. Worker A claims ship-HC-2048, which includes generating a customs document that normally takes seconds and today takes 95 s because a partner service is slow.
At t=60 s the lease lapses and the queue makes the job visible again. Worker B claims it and begins its own 95 s run. For roughly 35 s both workers work on the same job, and both attempt to create a shipment record and print a label. Each did precisely what the system allows; the queue assumed 60 s was enough.
Three defences work together. Workers extend the lease with a heartbeat while they are alive and making progress, so a slow job is not mistaken for a dead one. The shipment table's unique key on the job id makes the second creation fail harmlessly. And a fencing token (a number that increases at each claim) lets the table reject a write from the stale claimant. Setting the lease to an enormous value is the tempting shortcut and it delays recovery from a genuinely crashed worker by the same amount. Real products expose the same dial under different names: Amazon SQS hides a received message for a visibility timeout (30 seconds by default, at most 12 hours from the receive) and lets a worker extend it with ChangeMessageVisibility; a RabbitMQ quorum queue returns a delivery to the queue if the consumer holds it unacknowledged past a delivery acknowledgement timeout (30 minutes by default in current documentation) or closes its channel. In both, a consumer that outlives its claim can still be running, which is why the fence and the unique key matter.
| Facet | F13 at a glance |
|---|---|
| Trigger | Job duration of 95 s exceeds the 60 s visibility timeout. |
| Timeline | 1) t=0 s Worker A leases ship-HC-2048. 2) t=60 s lease expires; the job is visible again. 3) t=61 s Worker B claims it. 4) t=95 s A finishes and writes a shipment row. 5) t=156 s B finishes and hits the unique key, or writes a duplicate. |
| Impact | Double processing for about 35 s; duplicate labels or two shipments if the effect is not idempotent. |
| Root cause | Lease duration was shorter than the real service time; the broker cannot tell slow from dead. |
| Detection | Redelivery count per job; job duration histogram against the lease; duplicate shipment constraint violations. |
| Containment | Raise the lease for the slow class of jobs; let the unique key stop duplicates. |
| Recovery | Cancel duplicate labels and shipments found by the job id. |
| Prevention | Lease heartbeat, duration sized to p99 plus margin, idempotent effects, fencing token. |
| Guarantee that remains | At most one claim holds a valid lease at a time, though an expired claimant may still be running. |
| Tradeoff | Heartbeats add traffic and complexity; long leases slow crash recovery. |
| Invariant | I5, I6. |
F14 — Rolling deploy rebalances the group while batches are in flight
The email-notifier group has 12 consumers and 24 partitions, two partitions each. A rolling deploy restarts the consumers one by one. Each restart triggers a rebalance, which is the group coordinator redistributing partition ownership, and the group generation advances from 12 to 13 and onward. With an eager protocol, every member gives up all its partitions at each rebalance; with a cooperative protocol only the partitions that must move are revoked.
The duplicate arises in the gap. A consumer holds a polled batch (offsets 4,201 to 4,300 on partition 7) and has already sent 40 emails. The rebalance revokes the partition before the offset is committed and hands it to another member, which restarts at the last commit and sends those 40 emails again. For a short period two members may be handling the same offsets, one finishing, one beginning.
Cooperative rebalancing reduces the work interrupted: when one of twelve consumers dies, 24 partitions over 11 survivors means two take three partitions and nine keep two, and a sticky assignor moves only the two orphans. Static membership (each consumer keeps a fixed instance id, so a quick restart within the session timeout does not trigger a rebalance at all) is the other lever for rolling deploys. Handling revocation by committing progress for finished records narrows the window; only an inbox or an idempotency key at the email provider closes it.
| Facet | F14 at a glance |
|---|---|
| Trigger | Rolling deploy of 12 consumers advances the group generation while batches are in flight. |
| Timeline | 1) Consumer C3 polls offsets 4,201 to 4,300. 2) C3 has sent 40 emails. 3) A deploy restart triggers a rebalance; generation 12 becomes 13. 4) Partition 7 moves to C5, which resumes at 4,201. 5) C5 sends the same 40 emails again. |
| Impact | Customers receive duplicate emails; downstream rate limits may trip. |
| Root cause | Ownership moved before progress was committed; processing and ownership are not atomic. |
| Detection | Rebalance count and duration per deploy; duplicate event ids at the email provider; commit failures after revocation. |
| Containment | Slow the deploy rate; pause between restarts until the group is stable. |
| Recovery | Suppress or apologize for duplicates by event id; no state to repair. |
| Prevention | Cooperative assignment, commit on revocation, inbox dedupe keyed by event id, provider-side idempotency. |
| Guarantee that remains | Each partition has one owner per generation; duplicates are possible across a generation change. |
| Tradeoff | Cooperative protocols are more complex and slower to converge; smaller batches reduce the duplicate window but cost throughput. |
| Invariant | I5, I6. |
F15 — Orphaned consumer with a stale membership epoch writes anyway
Consumer C3 in sales-analytics hits a 50 s garbage-collection pause. The coordinator, hearing no heartbeat past the session timeout, declares C3 dead and gives its partitions to C5 at generation 13. C3 resumes still believing it owns partition 7 at generation 12.
C3's attempt to commit its offset is rejected, because the commit names an old generation; the broker fences it. That is good, and it is only half of the protection. The database insert that C3 issues before committing carries no generation at all, so the analytics table accepts it. C5 is by now processing the same offsets. The two write paths race, and the later write may overwrite a correct row with a stale one.
The offset fence guards the log, not your database. To protect external state, carry the generation (or any monotonically increasing claim number) into the write: UPDATE ... WHERE owner_generation <= :mine, or store it with the row and refuse anything lower. The same idea appears as a fencing token for locks and as the lease fence in F13.
| Facet | F15 at a glance |
|---|---|
| Trigger | A 50 s pause outlasts the session timeout; ownership moves while the old owner is still alive. |
| Timeline | 1) C3 pauses for 50 s. 2) Coordinator evicts C3; partition 7 goes to C5 (generation 13). 3) C3 wakes with generation 12 and continues its batch. 4) Offset commit is fenced; database writes are not. 5) C3 and C5 both write rows. |
| Impact | Stale or duplicate writes to analytics; possible overwrite of newer state. |
| Root cause | Fencing exists at the broker for commits but not at the external datastore. |
| Detection | Fenced-commit error counts; generation mismatches logged on write; unusual duplicate or out-of-order versions. |
| Containment | Restart the paused member; temporarily lower batch sizes. |
| Recovery | Rebuild affected rows from the log by replaying the partition for the window. |
| Prevention | Fence database writes with the generation or per-row version; keep the time between polls shorter than the maximum poll interval and tune the session timeout to the longest realistic pause. |
| Guarantee that remains | A fenced member cannot commit offsets for a partition it no longer owns. |
| Tradeoff | Per-write fencing adds a column and conditional update; shorter timeouts cause more false evictions. |
| Invariant | I4, I5. |
Poison Events, Retries, DLQs and Schemas
Failures that repeat deterministically need different handling from ones that pass. Here the subject is classification, bounded retries, parking, redrive and the schema changes that create poison events.
F16 — Poison event blocks a partition
A consumer reads partition 3 and meets an OrderPlaced whose payload fails parsing, because a bug in a producer wrote a truncated JSON body. The consumer throws, does not commit, and the next poll reads the same offset. In a log, one consumer owns a partition and reads strictly forward, so the group cannot skip it without an explicit decision.
The average rate on one partition is 833.3 events/s (20,000 over 24). While the consumer loops, a backlog grows by that much: after 10 minutes the partition is 500,000 events behind. Every key that hashes to that partition is delayed, which is 1/24 of traffic, while the other 23 partitions look fine and the aggregate lag graph hides the problem in an average.
The guard is a classification step. A permanent failure (the bytes cannot ever parse) goes to the dead-letter topic after a bounded number of attempts, with the offset committed past it, and the consumer continues. A transient failure (a timeout) is retried with backoff. Parking the event also breaks ordering for that key until someone decides what to do, and an operator must know which events are waiting.
| Facet | F16 at a glance |
|---|---|
| Trigger | One malformed record at partition 3 fails parsing on every attempt. |
| Timeline | 1) Consumer polls the malformed record and throws. 2) Offset is not committed; the same record is re-read. 3) Backlog on partition 3 grows at 833.3/s. 4) After 10 minutes the lag is about 500,000 events. 5) Alert fires on one partition's age. |
| Impact | 1/24 of keys stall; per-partition lag climbs while the aggregate stays moderate. |
| Root cause | No distinction between permanent and transient errors; the loop retries forever on a strictly ordered stream. |
| Detection | Per-partition lag and oldest message age; repeated exception on the same offset; retry counters. |
| Containment | Skip to the dead-letter topic by hand or deploy the classification fix. |
| Recovery | Fix the producer, correct the payload, and redrive from the DLQ after review. |
| Prevention | Schema validation on write, error classification, bounded attempts (max 5), DLQ with an alert. |
| Guarantee that remains | Other partitions are unaffected; order on the affected partition holds up to the parked record. |
| Tradeoff | Skipping a record trades strict per-key order for liveness; someone must handle the parked event. |
| Invariant | I6, I4. |
F17 — Infinite immediate retry creates a hot loop
The fraud-monitor consumer calls an inventory service that starts returning 503. The code catches the error and retries immediately in a tight loop with no limit. Each of the 12 consumers does this about 100 times per second, so a service that was already struggling absorbs 1,200 extra calls per second (hypothetical loop speed) at the moment it needs relief.
This is an amplification: the failing dependency's load rises because it is failing. Meanwhile the consumers make no progress, lag grows, and when the dependency recovers it faces the retry storm plus the real backlog together. Without jitter, consumers that failed together retry together, so the load arrives in synchronized waves.
Bounded retry with exponential backoff and jitter spreads the load: 2 s, 4 s, 8 s, 16 s plus random variation, at most 5 attempts, then the dead-letter path. A circuit breaker gives the dependency room to recover, and a retry budget limits the fraction of traffic that is retries. See retry with backoff and the retry storm module for the general mechanism.
| Facet | F17 at a glance |
|---|---|
| Trigger | Inventory returns 503; consumers retry immediately without limit. |
| Timeline | 1) Inventory begins failing. 2) Consumers retry in a tight loop at about 100/s each. 3) Inventory sees 1,200 extra requests per second. 4) Recovery is delayed; lag climbs. 5) Backlog and retries hit the dependency together when it returns. |
| Impact | Longer outage of the dependency; consumer lag; wasted capacity. |
| Root cause | Retry without backoff, jitter, a limit or a breaker. |
| Detection | Retry rate to the dependency; ratio of retries to first attempts; consumer lag; synchronized spikes in the dependency's request graph. |
| Containment | Turn on the breaker and cap attempts; temporarily pause the group. |
| Recovery | Resume gradually; let the backlog drain at a rate the dependency tolerates. |
| Prevention | Backoff with jitter, attempt cap, breaker, retry budget, DLQ. |
| Guarantee that remains | Each event is attempted a bounded number of times before it is parked. |
| Tradeoff | Longer backoff delays recovery of transient errors; parked events need handling. |
| Invariant | I6, I7. |
F18 — The dead-letter queue grows silently
After a schema mistake, about 40 events per minute (hypothetical) are sent to orders.dlq, which retains data for 14 days. The consumer group is healthy, lag is zero, and every dashboard is green, because parking an event counts as a success for the main pipeline.
The numbers accumulate quietly: 40 × 60 × 24 = 57,600 a day, and after 14 days 806,400 events. On day 15 the retention policy starts removing the oldest parked events and those orders are permanently gone, never examined. A DLQ is itself a queue with its own retention, so unattended it is only a slower way to lose messages.
In Amazon SQS the clock is even less forgiving: a message's expiry is measured from its original enqueue time and is not reset when it moves to the dead-letter queue, so AWS advises giving the DLQ a longer retention period than the source queue. Treat the DLQ as an operational inbox. Alert on depth, arrival rate, and oldest age (say, a page when the oldest is above 24 hours); tag each entry with the failure reason, original topic, partition, offset and attempt count; and give someone an owner and a runbook.
| Facet | F18 at a glance |
|---|---|
| Trigger | A steady trickle of failing events is parked and nobody watches the DLQ. |
| Timeline | 1) A producer change causes 40 events/min to fail validation. 2) Events move to orders.dlq; main lag stays at zero. 3) 57,600 per day accumulate. 4) Day 15: retention begins deleting the oldest parked events. |
| Impact | Orders never processed; eventual permanent loss of the oldest parked events. |
| Root cause | The DLQ had no owner, alert, or review cadence. |
| Detection | DLQ depth, arrival rate, oldest-message age; ratio of parked to consumed events. |
| Containment | Alert and triage immediately; extend DLQ retention while investigating. |
| Recovery | Fix the cause, then redrive with review (see F19) and idempotent processing. |
| Prevention | Alerts, owner, runbook, reason metadata, retention longer than the review cycle. |
| Guarantee that remains | A parked message is retained for the DLQ retention period and not beyond it. |
| Tradeoff | Longer retention costs storage and may hold personal data longer. |
| Invariant | I6, I12. |
F19 — DLQ redrive repeats a payment capture
A payment-capture job times out five times against the card processor and is parked. In reality the processor captured the funds on the first call and only the response was slow. HarborCart's incident shows 300 such parked jobs (hypothetical) after a processor brownout. An engineer bulk-redrives them into the main topic, and 300 customers are charged twice.
Parking an event does not prove the side effect did not happen. A timeout is the same unknown outcome as in F03. Redriving also reorders: the redriven job reaches the consumer after later events for the same order, so a refund for HC-2048 can be processed before the capture it refers to. FIFO guarantees, where they exist, apply to the original delivery within a key, not to the moment an operator reinjects a message.
For payment-capturing topics redrive is human-reviewed. Before reinjection, query the processor by idempotency key, send the idempotency key with each capture so that a repeat returns the earlier result, and redrive in small batches with the consumer's inbox in place. Where order matters, redrive to a separate topic or hold later events for the same key until the redrive completes.
| Facet | F19 at a glance |
|---|---|
| Trigger | Bulk redrive of 300 parked payment jobs whose captures had actually succeeded. |
| Timeline | 1) Processor slows; capture calls time out. 2) After 5 attempts the jobs move to the DLQ. 3) Operator redrives all 300. 4) The consumer calls the processor again. 5) Without an idempotency key, 300 customers are charged twice. |
| Impact | 300 double charges; refunds, support load and trust loss. |
| Root cause | Redrive assumed that parked meant not executed; the effect had no idempotency key. |
| Detection | Duplicate captures by order id; processor-side duplicate alerts; reconciliation of captures against orders. |
| Containment | Halt redrive immediately; freeze the topic. |
| Recovery | Refund duplicates, identify them by idempotency key, and reconcile ledgers. |
| Prevention | Idempotency keys at the processor, review before redrive, small batches, status checks, redrive into a controlled path. |
| Guarantee that remains | With idempotency keys, a repeated capture returns the earlier result instead of charging again. |
| Tradeoff | Human review slows recovery; idempotency requires the processor's support. |
| Invariant | I5, I6. |
F20 — A schema change breaks an older consumer
The OrderPlaced contract is on version 2 and fraud-monitor still reads v2. A producer team ships v3, renaming the required total field to totalAmount, through a hotfix path that skipped the registry's compatibility check. The registry is set to BACKWARD compatibility, meaning a new reader schema must be able to read data written under the old schema; that mode is there to make consumers upgrade first.
Here the producer went first. fraud-monitor deserializes the new records, finds no total, and treats it as 0, so every order now looks harmless to the fraud rules. Nothing crashes, which is worse than a crash. Other consumers fail loudly and park events by the thousand. The log is retained for 3 days, so poisoned events are recoverable but cannot be unwritten.
Compatibility has to be checked before the schema is accepted, enforced in CI and at runtime registration, with a non-bypassable path. Changes that break must go through a new topic or version, with a dual-write migration period. The check cannot prove semantic compatibility (a field that keeps its name but changes meaning).
| Facet | F20 at a glance |
|---|---|
| Trigger | v3 publishes totalAmount in place of total after a skipped compatibility check. |
| Timeline | 1) Hotfix deploys a v3 producer. 2) fraud-monitor (v2) finds no total field and defaults it to 0. 3) Risk scores are computed on 0. 4) Other consumers fail validation and park events. 5) Rollback begins. |
| Impact | Wrong fraud decisions; parked events; consumer errors. |
| Root cause | The compatibility gate was bypassed and the rollout order was producers first. |
| Detection | Deserialization errors, null-rate on required fields, schema registration audit, DLQ arrivals. |
| Containment | Roll the producer back; pause the affected consumers. |
| Recovery | Replay affected orders after reprocessing with corrected code; review decisions made on bad data. |
| Prevention | Registry with BACKWARD checks in CI, consumers upgraded first, no bypass, contract tests. |
| Guarantee that remains | Registered schemas are compatible with the configured mode; semantic changes are outside that proof. |
| Tradeoff | Compatibility rules slow change and force dual versions during migrations. |
| Invariant | I7, I12. |
F38 — A rolling upgrade changes protocol or schema compatibility
A new OrderPlaced v3 producer rolls out region by region, while consumers upgrade over a week. Halfway through, the schema registry accepts v3 because someone set the subject's compatibility to NONE for convenience. Old consumers that still decode with v2 begin failing on records from upgraded producers, and failed events retry, then flow to orders.dlq.
There are two layers. The wire protocol between clients and brokers has its own version negotiation, and a request using a version the other side does not support is refused with an explicit unsupported-version error. That is visible and loud. Schema compatibility is quieter: the data parses at the transport layer and breaks in the application. With BACKWARD compatibility, new schemas can read old data, which suits consumers upgraded first. Upgrade order matters: consumers before producers for backward changes, producers before consumers for forward ones.
A rolling upgrade should be a series of reversible steps with explicit gates: upgrade brokers, then clients, then flip the new schema or feature on only when the lowest-version member of every fleet understands it.
| Facet | F38 at a glance |
|---|---|
| Trigger | Registry compatibility relaxed to NONE; v3 producers deployed before consumers. |
| Timeline | 1) Subject compatibility set to NONE. 2) v3 producers deploy in EU. 3) Old consumers fail to decode; retries start. 4) Poison-like failures spill into the DLQ. |
| Impact | Failed processing for upgraded events; DLQ growth; possible delay in email and analytics. |
| Root cause | A compatibility gate removed, and rollout order not aligned with the compatibility direction. |
| Detection | Decode-error rate per consumer version, registry config-change audit, DLQ growth. |
| Containment | Roll the producers back or fall back to v2 serialization; pause redrive. |
| Recovery | Upgrade consumers, then redrive parked events after review. |
| Prevention | Keep BACKWARD (or stricter) compatibility; run contract tests with real old consumers; version-aware rollout gates. |
| Guarantee that remains | While compatibility is enforced, upgraded readers handle old data and the registry rejects breaking changes. |
| Tradeoff | Strict compatibility slows schema evolution and forces additive changes. |
| Invariant | I7, I6. |
Redrive, ordering and what a DLQ does not prove
Parking an event in a dead-letter queue records that processing failed, not that nothing happened. The consumer may have charged the card and then failed on the confirmation email, so a parked payment event is potentially a completed payment. That is why orders.dlq redrive for payment-capturing topics is human-reviewed, and why every consumer on the redrive path needs the same inbox check as on the live path: the event id decides whether the effect already exists.
Ordering is a second trap. A partition delivers its events in order to its single consumer, and that is the whole ordering promise. Once an event is parked, its successors for the same key continue on the main path, and when the parked event is later redriven it arrives after them, so OrderPaid may be processed before the redriven OrderPlaced. Redrive therefore cannot restore the original sequence; consumers need version numbers or state checks to tolerate it, or a stricter design pauses the whole key until the poison event is resolved. Queue-style FIFO guarantees are narrower still: strict first-in first-out breaks as soon as there are several consumers, retries or redeliveries, and is meaningful only inside one ordering scope such as a message group.
Load, Partitions and Capacity
Capacity problems in a log are mostly about where load lands. Hot partitions, idle consumers, slow groups, full disks and reconnect storms each need a different lever.
F21 — High-volume customer creates a hot partition
A partition is the unit of parallelism and of ordering: records with the same key go to the same one, and one consumer in a group owns it. Someone chooses tenant as the key, reasoning that per-tenant order is desirable. HarborCart's own harbor-retail tenant is 30% of the traffic.
At 20,000 events/s, 30% is 6,000 events/s, all of it hashed to a single partition, against a uniform share of 833.3 events/s (20,000 over 24). That partition receives 7.2 times its fair share, one broker's disk and one consumer's CPU absorb it, while the other 23 partitions run at about 609 events/s each (14,000 over 23), well within what a partition can carry. Adding consumers does nothing, since a partition has one owner at a time. Adding partitions does not help either, because the tenant is still one key.
The canonical key is order:<orderId>, which spreads a tenant's orders while keeping each order's events in sequence. The wider lesson is to pick the narrowest key that still carries the ordering the business actually needs. When a single entity really is that hot, split it with a salt (order:<id>:<n>) and accept that ordering applies within each salted lane, or move the heavy tenant to a dedicated topic with its own partitions.
| Facet | F21 at a glance |
|---|---|
| Trigger | Tenant-only partition key while one tenant produces 30% of events. |
| Timeline | 1) Producer hashes tenant to partition 3. 2) Partition 3 receives 6,000 events/s; others receive about 600 to 833. 3) The owning consumer saturates and lag builds on partition 3. 4) Adding consumers changes nothing. |
| Impact | Lag and latency on every event of the busy tenant; broker and consumer imbalance. |
| Root cause | Key cardinality too low for the load; ordering scope was set wider than needed. |
| Detection | Per-partition bytes in and lag; max-to-mean partition ratio (7.2 here); one partition's consumer at 100% CPU. |
| Containment | Raise that consumer's capacity; apply a tenant quota; temporarily shed low-priority events. |
| Recovery | Create a new topic with the correct key and migrate with dual publishing and consumer cutover. |
| Prevention | Choose order:<id> as key; monitor key skew; tenant quotas. |
| Guarantee that remains | Order within each key is preserved; across keys there is none. |
| Tradeoff | A finer key shrinks the ordering scope; a salt multiplies lanes that must be merged in order. |
| Invariant | I4, I7. |
F22 — Slow subscriber's backlog grows while other groups are healthy
orders has three independent consumer groups (named sets of consumers that share the partitions of a topic so each record is processed once per group). email-notifier and fraud-monitor are healthy. sales-analytics slows down because its database is under maintenance, and it manages 12,000 events/s against an arrival rate of 20,000.
Its backlog grows by 8,000 events/s: after 30 minutes, 14,400,000 events. In time terms the oldest unprocessed record is 14,400,000 ÷ 20,000 = 720 s, 12 minutes, old. Because each group keeps its own offsets, the slow group does not slow the other two, which is the whole point of the log over a shared queue. Retention, however, ignores consumers: the broker deletes by age or size whether or not the slow group has read the data, so a slow group is not a claim on disk but a race against the retention clock, and the end of that race is F23.
Lag measured in offsets and lag measured in age tell different stories. Offsets say the group is 14.4 million behind; age says 12 minutes. The second is what a business owner understands. Alert on both, and keep the answer to a lagging group proportionate: scale its consumers if partitions allow, fix the downstream, or accept lag for a non-urgent view such as analytics.
| Facet | F22 at a glance |
|---|---|
| Trigger | sales-analytics throughput falls to 12,000 events/s against 20,000 arriving. |
| Timeline | 1) Analytics DB slows; consumers cannot keep pace. 2) Backlog grows 8,000/s. 3) After 30 min: 14.4 million events behind, oldest record 12 min old. 4) Other groups remain current. |
| Impact | Stale analytics; no effect on email or fraud. |
| Root cause | Per-group throughput below the arrival rate; groups are independent by design. |
| Detection | Group lag by partition; oldest message age; consumer throughput versus arrival rate. |
| Containment | Reduce downstream pressure; scale consumers up to the partition count; pause non-critical replays. |
| Recovery | Drain at a rate above arrival; with 50,000/s against 20,000 arriving, 14.4 million drains in 480 s. |
| Prevention | Capacity planning per group, lag alerts, independent scaling. |
| Guarantee that remains | Isolation: one group's lag does not delay another group's delivery. |
| Tradeoff | Each independent group reads the log separately, so fan-out multiplies read bandwidth. |
| Invariant | I6, I12. |
F24 — Broker disk fills during a promotion burst
The promotion pushes ingest to 100,000 events/s for 10 minutes, five times the 20,000 baseline. At 1 KiB each, that is 97.66 MiB/s and 57.22 GiB gross over 600 s; the amount above the baseline is 45.78 GiB. With RF=3, every extra byte is stored three times. A broker that had less headroom than its share of that growth reaches a full disk, because time-based retention cannot free anything younger than 3 days.
Once a log directory fills, the broker stops being able to append and may take the affected partitions offline. Leaders move to the replicas, which hold the same data and may be nearly as full. The failure then cascades: failover adds load to survivors, replicas fall out of sync, and writes begin to be rejected (F07). Producers retry and the outbox grows.
The remedy starts before the disk: producers need a bounded buffer and a load shedding policy (drop or delay low-priority events), consumers need backpressure signals, and operators need an alert on projected time to full, using the rate of growth rather than a fixed percentage. Size for peak plus failure, not for the average, and decide in advance what is shed first.
| Facet | F24 at a glance |
|---|---|
| Trigger | A 10-minute 5x burst adds 45.78 GiB above baseline (57.22 GiB gross) before retention can reclaim anything. |
| Timeline | 1) Burst starts at 100,000 events/s. 2) Disks grow at roughly three times the ingest rate across replicas. 3) One broker's disk fills; its partitions go offline. 4) Leadership moves; survivors take extra load. 5) Writes begin to be rejected. |
| Impact | Rejected writes, outbox growth, delayed events, risk of cascading failure. |
| Root cause | No headroom for burst and no backpressure; retention could not free space. |
| Detection | Disk usage and growth rate; time-to-full projection; ISR shrink; producer error rate. |
| Containment | Shed low-priority topics; extend capacity; temporarily reduce retention on non-critical topics. |
| Recovery | Free space, let followers catch up, and drain the outbox. |
| Prevention | Capacity for the peak and a failed node; quotas; backpressure to producers; retention by bytes as a safety net. |
| Guarantee that remains | Records already committed remain durable; new writes are bounded by policy. |
| Tradeoff | Headroom costs money that mostly sits idle; shedding gives up some events deliberately. |
| Invariant | I7, I8. |
F25 — Reconnect storm saturates the metadata and control plane
A five-minute network blip drops the connections of 600 producers and 150 consumers (hypothetical fleet). They all reconnect within the same second, each requesting cluster metadata for 24 partitions, and the group coordinator receives join requests from every consumer at once.
The data path might survive, but the control path is a smaller, shared resource. Metadata requests queue, rebalances begin and restart as members time out, and the group enters a rebalance loop in which nobody processes records. The incident is self-inflicted: clients behave the same way at the same time because they run the same code with the same timeouts.
The remedy is jitter and limits on the client side (randomized reconnect backoff with a cap), server-side rate limits on metadata requests, longer session timeouts relative to blip duration, static membership for stable consumers, and separating the control plane's capacity from the data plane's. Rolling restarts should be paced too, since a deploy is a controlled version of the same storm.
| Facet | F25 at a glance |
|---|---|
| Trigger | Simultaneous reconnect of 750 clients after a five-minute network interruption. |
| Timeline | 1) Network returns. 2) All clients reconnect and request metadata within one second. 3) Coordinator and broker request queues fill. 4) Members time out and rejoin; rebalances repeat. 5) Throughput is near zero for minutes. |
| Impact | Group-wide stall even though brokers are healthy. |
| Root cause | Synchronized client behavior overloaded a small shared control plane. |
| Detection | Metadata request rate, rebalance count and duration, join request latency, connection count spikes. |
| Containment | Throttle connections at a load balancer; stop auto-restarting failing clients. |
| Recovery | Let the groups stabilize after the storm; restart in batches if necessary. |
| Prevention | Jittered backoff, metadata caching, quotas, static membership, bulkheads for control traffic. |
| Guarantee that remains | Committed data and offsets remain intact; only progress stalls. |
| Tradeoff | Jitter and slower reconnects lengthen recovery from short blips. |
| Invariant | I7, I12. |
F26 — Partition expansion changes key placement and ordering scope
To prepare for the promotion, the platform team grows orders from 24 to 48 partitions. Key-to-partition placement uses a hash modulo the partition count, so the mapping changes for roughly half the keys: a key stays only if its hash modulo 48 equals its hash modulo 24.
Take order:HC-2048. Its earlier events, OrderPlaced at partition 7, offset 4,201 and OrderPaid at 4,202, are on partition 7. A later OrderShipped hashes to partition 31, which is owned by a different consumer who may process it before the first consumer has finished the older events. For the period around the change, a per-key order that was guaranteed is no longer guaranteed, and the existing history cannot be moved without rewriting the log.
Expansion therefore is a planned migration. Quiesce or drain before expanding where order is essential; use the new partitions for new topics; or version the key with the partition count and have consumers tolerate a transition window. Expanding is also one-way (partition counts normally cannot shrink), so the cost of over-provisioning early is lower than the cost of expanding late.
| Facet | F26 at a glance |
|---|---|
| Trigger | Increase of partitions from 24 to 48 while events for open orders are in flight. |
| Timeline | 1) Operator doubles partitions. 2) About half of the keys now map to a new partition. 3) OrderShipped for HC-2048 goes to partition 31. 4) Consumer of 31 processes it before the earlier events on 7. |
| Impact | Out-of-order processing for about half of the keys during the transition; state-machine errors. |
| Root cause | Placement depends on the partition count; ordering is a per-partition property. |
| Detection | Per-key sequence gaps; out-of-order transition metrics; partition-count change audit. |
| Containment | Pause consumers briefly or buffer by version; avoid further changes. |
| Recovery | Reprocess affected keys in version order; compare state against the order database. |
| Prevention | Choose partition counts with headroom for peak (more than 50 for the hypothetical peak); plan expansions with a drain window. |
| Guarantee that remains | After the change, ordering is again per key within the new partition. |
| Tradeoff | Over-partitioning costs memory, file handles and rebalance time; under-partitioning forces risky expansions. |
| Invariant | I4. |
F39 — A few extremely heavy keys unbalance partitions
order:<id> keys spread HarborCart's load well in normal times, but a different topic keyed by sku carries one flash-sale item whose key produces 6,000 events/s, and a second hot SKU hashes to the same partition. That partition receives 12,000 events/s while the 24-partition uniform average is 833.3 events/s, about fourteen times higher. Under the hypothetical per-consumer capacity of 2,000 events/s, the owner of that partition is six times overloaded and lags without bound, however many consumers exist.
Unlike F21, where a whole tenant was concentrated, here the hot units are individual keys, and there are only a few. Adding partitions doesn't split one key; both keys will still map to some partition each (possibly the same one). Adding consumers doesn't help either, as a partition has a single owner within a group.
Real options are all about the key. Salt the hot key into several sub-keys (sku#0 to sku#7) and accept that order is then preserved only within each sub-key, or route hot keys to a dedicated topic, or handle the ordering requirement downstream. The first question is whether strict ordering of that key's events matters at all.
| Facet | F39 at a glance |
|---|---|
| Trigger | Two heavy SKUs hash to one partition: 12,000 events/s versus 833.3 uniform average. |
| Timeline | 1) Flash sale starts. 2) The hot partition's lag grows while others stay near zero. 3) Scaling the group has no effect. 4) On-call identifies the keys by sampling. |
| Impact | Unbounded lag for the entire partition, including unrelated keys that share it. |
| Root cause | Key skew: a unit of ordering that exceeds one consumer's capacity. |
| Detection | Per-partition bytes and lag skew; top-N keys by volume; one partition's lag diverging from the rest. |
| Containment | Divert the hot keys to a dedicated topic or salt them; shed non-critical consumption for that partition. |
| Recovery | Drain the backlog with temporary parallel consumers only where ordering is not required; verify results. |
| Prevention | Choose keys with enough cardinality, monitor skew, plan salting for known hot entities, test with realistic key distributions. |
| Guarantee that remains | Events for a given key remain ordered within one partition (unless salted). |
| Tradeoff | Spreading a key across sub-keys buys throughput and gives up total ordering for that key. |
| Invariant | I4, I7. |
F40 — Scaling the group beyond the partition count changes nothing
The sales-analytics group is behind during a promotion. It has 24 consumers for 24 partitions, each with the hypothetical 2,000 events/s capacity, so the group tops out at 48,000 events/s against 100,000 arriving. The team scales to 40 consumers. Sixteen of them join, receive no partitions, and sit idle. Throughput stays 48,000 events/s, the bill grows, and lag keeps growing at 52,000 events/s.
In a Kafka-style group each partition has exactly one owner at a time, so the partition count is the upper bound on parallel consumption. Extra members act as standbys that can take over if an active one fails. To process 100,000/s at 2,000 per consumer you would need 50 partitions' worth of owners, so the real choices are more partitions, faster consumers, or accepting lag until the burst ends.
Raising the partition count has side effects (F26): key placement changes and ordering across the transition is not preserved, so it is a planned operation rather than an emergency lever. A queue with competing consumers on one shared list, such as the shipment-jobs work queue, behaves differently, since workers are not bound to partitions.
| Facet | F40 at a glance |
|---|---|
| Trigger | 24 partitions; group scaled from 24 to 40 consumers during a 100,000 events/s burst. |
| Timeline | 1) Burst begins; lag climbs at 52,000 events/s. 2) Team adds 16 consumers. 3) Rebalance completes; 16 members hold no partitions. 4) Lag keeps growing. |
| Impact | No reduction in lag; extra cost; a needless rebalance. |
| Root cause | Parallelism is capped by partition count in a group-based log. |
| Detection | Active versus idle member counts; per-member assigned partitions; lag slope unchanged after scaling. |
| Containment | Reduce the group back; make downstream work cheaper; shed low-priority processing. |
| Recovery | Plan partition expansion with the key-placement consequences understood; drain the backlog when the burst ends. |
| Prevention | Size partitions for peak parallelism ahead of time, with headroom; measure per-consumer capacity; document the ceiling. |
| Guarantee that remains | Each partition is consumed by one group member at a time, so per-partition order is kept. |
| Tradeoff | More partitions raise parallelism and also metadata, file handles and rebalance cost. |
| Invariant | I4, I7. |
Retention, Replay and Compaction
A retained log promises replay only within limits. These three incidents show consumers that assumed more history than the log was configured to keep.
F23 — Retention deletes messages before a lagging group catches up
The log keeps records for 3 days by time, regardless of whether anyone has read them. A deploy accidentally leaves sales-analytics paused. Nobody sees it over a long weekend, and 4 days pass.
By the third day the oldest unread offsets cross the retention boundary and the broker deletes whole segments from the log's start. When the group restarts, its committed offset points at data that no longer exists, an out-of-range error. The reset policy decides what happens: earliest jumps to the oldest retained record and silently skips what was deleted, while latest jumps to the end and skips everything. One day of data at 20,000 events/s is 1,728,000,000 events, permanently missing for that group, though other groups that kept up have it processed.
Committed offsets are also subject to expiry once a group is empty for a long time (the default is 7 days in recent Kafka versions), so a long outage can lose the bookmark itself. The defense is to alert on lag age against retention (for example, page at 50% of retention), keep retention longer than the longest tolerable outage including weekends, and have a rebuild path from the source of truth.
| Facet | F23 at a glance |
|---|---|
| Trigger | A paused group stays idle for 4 days against a 3-day retention. |
| Timeline | 1) Group stops on day 0. 2) Day 3: oldest unread segments become eligible for deletion. 3) Day 4: group restarts; committed offset is out of range. 4) Reset policy skips the missing day. |
| Impact | Missing analytics for about 1.73 billion events (1 day); wrong totals unless rebuilt. |
| Root cause | Retention is time-based and independent of consumer progress. |
| Detection | Lag age as a percentage of retention; out-of-range errors; consumer group state empty for long. |
| Containment | Extend retention for the topic now, before more segments expire. |
| Recovery | Rebuild the gap from the order database or snapshots; replay what remains. |
| Prevention | Retention longer than worst outage; alerts at fractions of retention; runbook for restart. |
| Guarantee that remains | Replay is bounded by retention; the log never promised more. |
| Tradeoff | Longer retention costs disk and extends the time sensitive data is held. |
| Invariant | I9, I12. |
F35 — Compaction removes history a consumer assumed was there
The customer-address topic is compacted: the broker keeps at least the latest record for each key and eventually discards older ones. A new analytics job starts from the beginning of the topic expecting every address change since launch, so that it can build an address-history report. What it reads is the latest address per customer, plus whichever older values had not yet been cleaned.
Compaction is a state-snapshot feature, not an archive. It lets a late consumer rebuild current state without replaying every update, and in exchange gives up intermediate values. Even for the latest value there is a subtlety: a delete is recorded as a tombstone (a record with a null value), which is itself removed after delete.retention.ms (24 hours by default). A consumer that is offline longer than that can miss that the key was deleted.
The report built on that data looked plausible, which is the real danger: no error, just missing history. The fix is to put history where it belongs. Keep a retained, non-compacted event topic, or an event-sourced store or warehouse, for audits, and let the compacted topic feed only the current-state view.
| Facet | F35 at a glance |
|---|---|
| Trigger | Analytics job assumes a compacted topic contains the full change history. |
| Timeline | 1) Job starts at the earliest offset. 2) It reads only surviving records, mostly one per key. 3) Report shows one address per customer. 4) Finance asks why history is missing. |
| Impact | Silently incomplete analytics and audit results. |
| Root cause | Misunderstanding the retention semantics of a compacted topic. |
| Detection | Record-count and key-cardinality comparison against the source of truth; reconciliation of reports. |
| Containment | Withdraw the report; mark the topic's semantic in its catalog entry. |
| Recovery | Rebuild history from the database change log or a non-compacted topic where one exists. |
| Prevention | Declare cleanup policy and its meaning in the topic catalog; use a separate retained topic for history. |
| Guarantee that remains | A compacted topic retains at least the latest value per key (until a tombstone expires). |
| Tradeoff | Compaction bounds storage and speeds state rebuild, but intermediate states are not recoverable. |
| Invariant | I9. |
F36 — A consumer starts from an offset outside retention
The fraud-monitor group is paused for a long weekend of maintenance, for 5 days. Retention on orders is 3 days, so the oldest 2 days of what it still needed has been deleted. On restart the group asks to resume from offset 3,100 on partition 7; the log now starts at 3,980. The broker reports the position is out of range.
What happens next is a configuration decision someone made long ago: the reset policy. With 'earliest', the group jumps to the oldest available record and silently skips roughly 880 events on that partition. With 'latest', it jumps to the end of the log and skips everything not yet read, and with 'none' it fails loudly and waits for a human. The wrong default on a fraud consumer is worse than the outage.
For a consumer whose misses are costly, 'none' is the safer default: the group stops, and an operator chooses the restart point deliberately (an offset looked up by timestamp, or the oldest retained record plus a backfill job for the gap). The expiry of committed offsets described in F23 applies here as well; a group empty for longer than the offsets retention period would not even have offset 3,100 to ask for.
| Facet | F36 at a glance |
|---|---|
| Trigger | Group down for 5 days against 3-day retention. |
| Timeline | 1) Group stops for planned maintenance. 2) Two days of its unread data age out. 3) Group restarts and requests offset 3,100. 4) Broker reports out-of-range; reset policy applies. |
| Impact | Missed fraud signals for the deleted range, or a stalled group, depending on the policy. |
| Root cause | Downtime exceeded retention, and the recovery behavior was left on a default. |
| Detection | Offset-out-of-range errors; alert on lag as a fraction of retention (consumer-lag-to-retention ratio). |
| Containment | Stop the group; set reset behavior deliberately; assess the missed range. |
| Recovery | Rebuild state from the system of record or an archive; backfill the fraud model with a batch job. |
| Prevention | Alert when lag approaches 50% of retention; extend retention for critical groups; archive to cheaper storage; choose reset policy explicitly. |
| Guarantee that remains | Replay is possible only inside retention and authorization limits. |
| Tradeoff | Longer retention costs storage across three replicas; archives add another system to operate. |
| Invariant | I9, I12. |
Tenancy, Payloads and Data Protection
A shared platform needs identity, bounded inputs and controlled configuration. Each of these incidents is a trust boundary that was drawn in the wrong place.
F30 — Cross-tenant subscription attempt
A second tenant, meridian, runs a legacy integration against HarborCart's platform. Its subscription request carries a tenantId field in the request body, and the legacy gateway trusts it. A bug (or an attacker) sets it to harbor-retail. The gateway authorizes the subscription and starts delivering harbor-retail orders to Meridian's client.
The failing assumption was that the tenant is something the caller says. Identity has to come from the credential: the authenticated principal maps to a tenant, and topic ACLs grant that principal specific operations on specific topics. A payload field can describe the data; it cannot authorize access to it. In this scenario, about 1,000 events (hypothetical) were delivered before an alert, including customer addresses.
Containment means revoking the credential and listing every delivered offset. Correct enforcement checks the ACL on the verified principal at subscribe time and on every fetch, logs denials, and treats a denial pattern as a security signal. Quotas keep a single tenant from using the shared cluster as an exfiltration channel.
| Facet | F30 at a glance |
|---|---|
| Trigger | Subscribe request names another tenant in its payload and the gateway trusts it. |
| Timeline | 1) Meridian client sends tenantId=harbor-retail. 2) Legacy gateway authorizes by payload. 3) Events begin flowing to Meridian. 4) Anomaly alert fires after about 1,000 events. |
| Impact | Exposure of another tenant's order data; regulatory and trust consequences. |
| Root cause | Authorization derived from untrusted input instead of the verified principal. |
| Detection | ACL denials and grants audit; unusual consumer group on a restricted topic; bytes read per principal. |
| Containment | Revoke the credential; block the group; snapshot logs. |
| Recovery | Notify per policy; rotate credentials; reconcile exposed offsets. |
| Prevention | Topic ACLs bound to the verified principal, deny by default, per-tenant quotas, audit. |
| Guarantee that remains | With ACLs on the principal, a caller can read only topics it is explicitly granted. |
| Tradeoff | Fine-grained ACLs add operational overhead and onboarding steps. |
| Invariant | I1. |
F31 — A huge or hostile payload overwhelms the validator
HarborCart caps a single event at 1 MiB (an illustrative limit) while the average order event is 1 KiB, so the cap is 1,024 times the typical size. A compromised integration for tenant meridian starts publishing 1 MiB documents full of deeply nested JSON, at 400 requests/s. That is about 400 MiB/s of inbound traffic, roughly twenty times the entire 19.53 MiB/s average ingest of the platform, and each payload makes the schema validator do real parsing work before the broker can reject or accept it.
The harm arrives from two directions. Network and CPU on the ingest tier are consumed first, so legitimate producers see timeouts. Anything that does get through is replicated three times and held for three days, so a few minutes of abuse leaves a long tail of useless bytes on disk. A size check that runs after full deserialization protects nothing, because the expensive step has already happened.
The defense is ordered cheapest-first: reject on the declared length before reading the body, apply a per-principal byte-rate quota, then validate with a bounded parser (depth and field-count limits) and only then append. Quota rejections should be explicit and retryable so a well-behaved client backs off rather than hammering.
| Facet | F31 at a glance |
|---|---|
| Trigger | One tenant sends 1 MiB nested documents at 400 requests/s against a 1 KiB average and a 1 MiB cap. |
| Timeline | 1) Requests arrive carrying large declared lengths. 2) Ingest nodes read and parse full bodies before checking size policy. 3) CPU and bandwidth saturate; healthy producers time out. 4) Accepted payloads are replicated and retained. |
| Impact | Publish latency spikes for every tenant; wasted storage and replication bandwidth; possible spillover to unrelated topics. |
| Root cause | Resource limits applied after the costly work instead of before it, and no per-principal byte-rate bound. |
| Detection | Bytes in per principal, rejected-request counts, p99 of validation time, histogram of payload sizes with a sudden upper tail. |
| Containment | Throttle or suspend the offending principal; lower the effective cap temporarily; keep other tenants' quotas intact. |
| Recovery | Purge or tombstone the oversized records if policy allows, rotate the credential, and review the integration. |
| Prevention | Reject on declared length early, per-principal byte and request quotas, bounded parser depth, schema checks before append. |
| Guarantee that remains | Accepted records fit the declared envelope and caps; one principal cannot exceed its quota. |
| Tradeoff | Tight limits will reject some legitimate large documents; those need a claim-check pattern (store the blob elsewhere, publish a reference). |
| Invariant | I7 (everything bounded), I1. |
F32 — An unauthorized retention override exposes customer data
Retention on orders is three days, and each event includes shipping addresses. An engineer debugging a replay request sets the topic's retention to unlimited from a shared admin console that did not require a specific permission. Weeks later a security review finds roughly a month of address data still sitting in the log, readable by every group that is allowed to consume orders, and past the period the privacy policy promised.
Two separate mistakes combined. First, configuration was a privilege that nobody had to justify: altering topic settings is a control-plane action that deserves its own ACL and audit record. Second, the log was holding personal data in plain form. An append-only log cannot easily delete one customer's record, so deletion rules are best implemented by limiting what goes in (redact or tokenize before append), encrypting sensitive fields with per-subject keys that can be destroyed, and keeping retention short by policy.
After discovery the immediate step is to restore the intended retention; segments past it are removed on the next cleanup. Removing earlier copies that were mirrored or replayed into other systems is a separate hunt, because the log is not the only place that data lives.
| Facet | F32 at a glance |
|---|---|
| Trigger | Retention on a PII-bearing topic raised to unlimited without approval. |
| Timeline | 1) Engineer needs old events for a replay. 2) Admin console permits the config change. 3) Retention stays unlimited for about a month. 4) Audit finds address data beyond the stated window. |
| Impact | Privacy-policy breach; larger blast radius for any later read-access mistake; unplanned storage growth. |
| Root cause | Topic configuration not protected by a dedicated ACL and change review; sensitive fields stored unredacted. |
| Detection | Config-change audit log, drift detection comparing live topic config to declared config, storage growth on a single topic. |
| Containment | Reset retention to the declared value; freeze admin config changes pending review. |
| Recovery | Verify segment deletion; trace copies in replays, mirrors, DLQs and analytics; apply the data-protection procedure. |
| Prevention | Config changes through reviewed code, ACL on alter-config, field-level redaction or encryption before append, retention alerts. |
| Guarantee that remains | Authorized configuration drives retention; replay never reaches past declared retention. |
| Tradeoff | Crypto-shredding and redaction add key management and make debugging harder; short retention narrows replay. |
| Invariant | I11, I9, I1. |
Regional Failures and Asynchronous Mirrors
Two regions give you two failure domains and a policy question about what may cross between them. Both incidents are about what a regional failover can and cannot preserve.
F33 — A region fails while residency policy forbids mirroring
HarborCart runs two independent messaging fabrics, EU and US. Topic orders.eu carries European customers' data and is tagged so it is never copied across the border. At 14:02 the EU fabric loses most of its brokers because of a provider incident. US operations ask whether they can simply route EU checkouts to the US fabric until it recovers.
The honest answer depends on policy, not on engineering convenience. Routing EU events into the US would violate the residency rule, so it is off the table. The EU checkout path therefore has a defined degraded mode: accept orders into each region's local database and outbox, hold the events in the outbox until the EU fabric returns, and show customers an honest 'confirmation will follow' state. Nothing is lost provided the outbox survives, and the relay drains the backlog afterward.
Non-resident topics (orders.global, say) can be mirrored asynchronously and fail over, with a cost (see F34). Residency-tagged topics trade that failover for compliance. The business should know in advance which side of that line each topic is on, rather than discover it during an outage.
| Facet | F33 at a glance |
|---|---|
| Trigger | Majority of EU brokers unavailable; EU topics are residency-restricted. |
| Timeline | 1) EU brokers fail at 14:02. 2) EU publishers see timeouts; outboxes begin accumulating rows. 3) Ops confirm that cross-border rerouting is not permitted. 4) Degraded checkout messaging is enabled for EU. 5) The EU fabric returns and relays drain the backlog. |
| Impact | EU confirmations, notifications and analytics are delayed; US operations are unaffected. |
| Root cause | A regional dependency with no permitted failover target, by design. |
| Detection | Regional broker health, outbox row age per region, publish error rate by region. |
| Containment | Enable degraded mode: keep accepting orders into the local database, show delayed-confirmation messaging, protect the outbox from growth limits. |
| Recovery | Let the relays drain at a controlled rate; verify consumers dedupe; confirm no cross-border data movement occurred. |
| Prevention | Per-region capacity and redundancy across zones, documented topic residency classes, game-day rehearsal of degraded mode. |
| Guarantee that remains | Resident data never leaves its region; committed orders are preserved by the local database and outbox. |
| Tradeoff | Compliance reduces availability: during a regional incident, EU workflows slow down rather than fail over. |
| Invariant | I8, I10, I11. |
F34 — Async mirror falls behind, then is promoted with a missing suffix
For a non-resident topic, HarborCart replicates US to a standby cluster asynchronously. During a busy hour the mirror link slows, and the standby trails by 12 s. At 20,000 events/s that gap is 240,000 events, about 234 MiB at 1 KiB each (illustrative). The primary region then fails and the team promotes the standby.
The standby is a consistent prefix of the primary's log, but it is missing the last twelve seconds. Producers acknowledged those events under the primary's replication policy; the standby never saw them. After promotion, consumers either re-read events they had already handled (their offsets from the old cluster do not map onto the new log) or silently skip the gap, depending on how offsets were translated.
This is the price of asynchronous cross-region copying: recovery point is greater than zero. The decision to promote must weigh that loss against continued unavailability, and the missing events can often be rebuilt from upstream sources of truth. For orders, the order database and outbox still hold them, and the relay can republish with the same event ids so inbox dedupe absorbs the overlap. If the old primary comes back holding that missing suffix, it cannot simply resume as primary: its log has diverged from the promoted cluster's, so it must be rebuilt or reconciled before any failback.
| Facet | F34 at a glance |
|---|---|
| Trigger | Primary region lost while the mirror is 12 s behind at 20,000 events/s. |
| Timeline | 1) Mirror link slows; lag grows to 12 s. 2) Primary region fails. 3) Standby promoted. 4) Consumers resume against a log missing 240,000 recent events. 5) Outbox republish fills the gap. |
| Impact | Missing suffix of acknowledged events; offset translation ambiguity; duplicates after republish. |
| Root cause | Asynchronous replication across regions guarantees a bounded-lag copy, not zero data loss. |
| Detection | Mirror lag in seconds and events; alerts before the lag exceeds the declared recovery point; consumer offset translation checks. |
| Containment | Decide on promotion with the recovery point known; freeze consumers until offsets are mapped. |
| Recovery | Republish from the order database or outbox with stable ids; reconcile counts per partition; dedupe with inbox tables. |
| Prevention | Alert on mirror lag, document the recovery-point objective, keep an authoritative source for each topic, test the failover. |
| Guarantee that remains | After promotion the standby log is a consistent prefix of the old primary's partitions; events republished with the same ids are deduplicated. |
| Tradeoff | Synchronous cross-region replication could close the gap, at the cost of write latency on every publish. |
| Invariant | I8, I10. |
Observability Failures
You can only run what you can see accurately. One incident misreads a metric because of clocks, and the other loses all visibility because monitoring shares the broken cluster.
F29 — Clock skew misreports lag
A team builds a lag dashboard from event time: now minus the eventTime field the producer wrote. One producer fleet's clocks are 180 s behind. The dashboard shows email-notifier three minutes late, even though it consumed the event milliseconds after it was appended.
The on-call engineer sees a red graph and scales the consumer group from 12 to 16. Partitions are 24, so scaling is possible but not needed, and a rebalance is triggered for nothing. In the opposite direction, a fleet whose clocks run fast makes real lag look smaller or even negative, hiding a true delay.
Use two measures and understand each. Offset lag (the log end offset minus the committed offset) uses no clock. Oldest message age uses the broker's append timestamp, which relies on the broker's clock instead of the many producer clocks. Event-time lag has value for business questions such as 'how stale is this report,' and it must carry an error bar from clock synchronization.
| Facet | F29 at a glance |
|---|---|
| Trigger | Producer clocks 180 s behind real time; lag is computed from event time. |
| Timeline | 1) Dashboard subtracts eventTime from now. 2) It reports 180 s of lag that does not exist. 3) On-call scales the group. 4) A rebalance occurs with no benefit. |
| Impact | Wasted action and a rebalance; real lag could be hidden by fast clocks. |
| Root cause | A client-supplied timestamp was used as a measurement of system delay. |
| Detection | Compare offset lag, broker-append age and event-time lag; NTP offset monitoring. |
| Containment | Trust offset lag first; disable the event-time alert. |
| Recovery | Fix the clock synchronization; redefine the metrics. |
| Prevention | Alert on offset lag and append-time age; keep clocks synchronized; annotate event-time metrics with skew. |
| Guarantee that remains | Offset lag is clock-independent and exact for log position. |
| Tradeoff | Append-time age measures time in the log, not time since the business event occurred. |
| Invariant | I12. |
F37 — The metrics pipeline depends on the broken cluster
HarborCart ships broker, consumer and lag metrics through the same message platform that it monitors. When a controller problem makes orders unavailable, the telemetry topic stalls too. Dashboards flatline at 09:14, which looks like 'quiet', and the alert that would have fired never arrives because it depends on the same path.
A monitoring system that shares fate with the monitored system fails exactly when it is needed. The pattern appears in many incidents: the status page needs the database that is down, the on-call tool needs the VPN that is down. Here the symptom is subtle because a missing series reads as zero lag, and the graph looks healthy.
The remedy is independence. Brokers and clients should expose metrics through a path that doesn't traverse the cluster (scraped over HTTP by a separate monitoring stack), alerts should treat missing data as a signal (a dead-man's switch that fires when no heartbeat arrives), and there should be a synthetic producer-consumer probe running end to end with its own alert route.
| Facet | F37 at a glance |
|---|---|
| Trigger | Telemetry published via the same cluster that is failing. |
| Timeline | 1) Controller trouble degrades publish and fetch. 2) Telemetry topic stops flowing. 3) Dashboards go flat at 09:14. 4) Support tickets, not alerts, reveal the incident. |
| Impact | Detection delayed until customers report; longer incident duration. |
| Root cause | Shared fate between monitoring and the monitored system. |
| Detection | Heartbeat absence, synthetic probe failure, out-of-band checks. |
| Containment | Switch to out-of-band metrics and logs; page by the dead-man's-switch route. |
| Recovery | Backfill missing series where possible; mark the gap on dashboards. |
| Prevention | Separate metrics path, absent-data alerts, synthetic end-to-end probe, runbooks that don't need the cluster. |
| Guarantee that remains | Lag, age, under-replication and dead-letter counts stay visible independently of data-plane health. |
| Tradeoff | A second monitoring path costs money and upkeep; it is the price of seeing outages. |
| Invariant | I12. |
How to Run This Platform: Signals, Game Days and Rules
The incidents reduce to a short list of signals, a few drills and some standing rules. Having them before the first failure turns most of the forty into short events.
Signals worth paging on, and signals worth a ticket
| Signal | What it tells you | Typical response | Incidents |
|---|---|---|---|
| Publish error rate and unknown-outcome count | Producers cannot tell whether events landed (F03) | Page when sustained; check broker health and idempotent publishing | F03, F04, F09 |
| Under-replicated partitions and ISR size | Replication is behind or a broker is gone | Page; with ISR at one writes are at risk | F05, F06, F07, F08 |
| Consumer lag in records and oldest-record age, per group and partition | Backlog and its business impact | Page on age relative to retention; ticket on slow growth | F21, F22, F23, F36, F39, F40 |
| Retries per event, DLQ depth and DLQ age | Poison or systemic downstream failure | Page on rate change; ticket on age | F16, F17, F18, F19 |
| Rebalance count and duplicate-id hits in inbox tables | Ownership churn and redelivery | Ticket; page if sustained during deploys | F11, F14, F15 |
| Lease expirations versus job duration | Leases too short | Ticket and tune | F13 |
| Outbox oldest unpublished row | Relay stalled or broker down | Page past the freshness objective | F27, F28, F33 |
| Disk and quota headroom per broker and tenant | Burst or abuse approaching limits | Page before full | F24, F31 |
| Controller, metadata and connection counts | Control-plane stress | Page; apply client backoff | F25, F37 |
| Config and ACL change audit; schema registry changes | Unreviewed changes | Review | F20, F30, F32, F38 |
| Mirror lag in seconds | Recovery point is growing | Page above the objective | F34 |
| Heartbeat and synthetic probe | Monitoring is alive and end-to-end works | Page when absent | F37 |
Set alerts relative to consequences. Lag of 3,000,000 records may be a minute at peak or an hour at night; oldest-record age and the ratio of lag to retention say far more. A consumer-lag figure alone cannot tell you the difference between a healthy burst and a stuck group.
Game days worth running
- Kill one broker during load and confirm writes continue with two in-sync replicas, then kill a second and confirm writes are refused rather than lost (F05 to F07).
- Crash a consumer after the database commit and before the offset commit; confirm the replay is absorbed by the inbox table (F11).
- Run a rolling deploy of consumers and count duplicates per rebalance (F14, F15).
- Publish a poison event and verify it reaches the DLQ after five attempts without stalling the partition (F16, F17).
- Pause a group for longer than half of retention and check that the alert fires before data expires (F23, F36).
- Fail the EU fabric and rehearse the degraded mode and outbox drain (F33).
- Fail the cluster and confirm that the dead-man's-switch alert still arrives (F37).
- Acknowledging or committing before the business effect is durable (F12).
- Treating a DLQ as proof that the effect did not happen (F19).
- Trusting a tenant id in the payload instead of the verified principal (F30).
- Using event-time lag alone to decide on scaling (F29).
- Scaling consumers past the partition count and expecting more throughput (F40).
- Relaxing schema compatibility to ship faster (F20, F38).
- Monitoring a cluster through the same cluster (F37).
Verification notes
Platform defaults quoted here were checked against the Apache Kafka source (producer delivery timeout of 120 s, at most five in-flight requests with idempotence, consumer session timeout of 45 s, offsets retention of 7 days, tombstone retention of 24 hours, log retention of 7 days by default, unclean leader election off by default). Consumer acknowledgment, automatic requeue on channel close and the delivery acknowledgement timeout come from the RabbitMQ documentation; the SQS visibility timeout, five-minute FIFO deduplication interval and dead-letter expiry rule come from the Amazon SQS developer guide. Behavior and defaults change between versions, and managed-service limits vary, so read the current documentation of whatever you run. Every HarborCart figure is illustrative.
- 01Every incident sits on a boundary between two delivery states; name both states and the failure usually becomes clear.
- 02Duplicates are normal under at-least-once delivery; stable event ids and inbox tables make them harmless.
- 03Committed data survives only the failures the replication policy covers, and write availability is a separate promise.
- 04A DLQ parks events; it does not prove the effect did not happen, and redrive can reorder.
- 05Parallelism is bounded by partitions, ordering by the key, replay by retention.
- 06Monitoring has to survive the failure of the thing it watches.