Idempotent consumers

3 min read
On trails:EDARELstep 3/9

A consumer is idempotent when handling the same message twice has the same effect as handling it once. With at-least-once deliveryDelivery guaranteesAt-most-once, at-least-once and exactly-once: what a broker promises, and what it cannot.Opens in a new tab the broker will sometimes send a message again, so the consumer has to cope.

How a duplicate happens

Orders receives PaymentReceived, marks the order paid and saves. Before it acknowledges the message, the process crashes. The broker sees no acknowledgement, the lock expires, and another instance receives the same message. Without protection the order is marked paid twice, a confirmation email goes out twice, and a counter goes up twice. Producer retries and messages resubmitted from a dead-letter queueDead-letter queuesWhere a message goes when it cannot be processed, so the rest of the traffic keeps moving.Opens in a new tab cause duplicates too.

Make the operation safe to repeat

Some operations need no extra bookkeeping.

OperationSafe to repeatWhy
Set the status to PaidYesSame end state each time
Upsert a row by its business keyYesThe second run finds the row and rewrites the same values
Add 10 to the balanceNoEach run adds again
Reduce stock by 1NoEach run subtracts again
Send an emailNoThe recipient gets one per run
Table 1: which operations survive a repeat

An event that carries the resulting value, such as the order’s new status, can be applied as a set or an upsert. An event that carries a change, such as minus one, cannot.

When it is not natural: the inbox

For the rest, the consumer keeps a table of message IDs it has finished, called an inbox. It checks the table first, and it writes the ID in the same database transaction as the change.

PaymentReceivedHandler.csC#
public sealed class PaymentReceivedHandler(ShopDbContext db){    public async Task HandleAsync(PaymentReceived msg, CancellationToken ct)    {        var seen = await db.Inbox.AnyAsync(i => i.MessageId == msg.MessageId, ct);        if (seen) return;         var order = await db.Orders.FindAsync([msg.OrderId], ct);        order!.MarkPaid(msg.PaymentId);         db.Inbox.Add(new InboxEntry(msg.MessageId, DateTimeOffset.UtcNow));        await db.SaveChangesAsync(ct);    }}

The check alone is not enough. Two instances can receive copies at the same moment, and both pass the check before either saves. A unique index on MessageId closes that gap: the second SaveChangesAsync fails, the message is retried, and the retry finds the row. The save on line 12 is one transaction, so a crash leaves either both the order change and the inbox row, or neither (see DbContextORMs, DbContext and entitiesWhat EF Core is, and the four ideas its API is built on.Opens in a new tab). The producer-side partner is the outbox, in outbox and inboxOutbox and inboxWrite the event in the same transaction as the data, publish it from there, and deduplicate on the way in.Opens in a new tab.

Details that matter

  • Key on a stable ID the producer assigns, such as MessageId, or on a business key. A CorrelationId is shared by related messages, and a broker’s delivery tag changes on every delivery.
  • Keep inbox rows at least as long as a redelivery is possible, which includes a message resubmitted from a dead-letter queue days later. Then delete them.
  • If two services consume the same event, each keeps its own record of it.
  • A call to an outside service cannot join the transaction. Pass the message ID on as an idempotency key so the other side can deduplicate, or record the key as in progress, make the call, then mark it completed.
  • Broker-side duplicate detection, such as Service Bus’s, drops repeated sends within a time window (10 minutes by default). It does not cover a redelivery to a consumer, so it does not replace this.

Sources