Skip to main content
Create your own

Idempotent Event Handling for Exactly-Once Processing

Hello! Welcome back.

In our last lesson on rebuilding projections, we established that idempotent event handlers are not just a best practice but a fundamental requirement for creating reliable and evolvable event-sourced systems. Replaying events, by its nature, means processing them again, making idempotency non-negotiable.

Today, we will perform a deep dive into this critical concept.

Lesson 4: Designing Idempotent Event Handlers

Learning Outcome: By the end of this lesson, you will be able to design an idempotent event handler to ensure exactly-once processing semantics.

Achieving exactly-once delivery is a notoriously hard problem in distributed systems. However, we can achieve exactly-once processing semantics by combining an at-least-once delivery mechanism (common in brokers like Kafka) with an idempotent consumer. This is a cornerstone pattern for building robust systems, especially in domains like finance and payments where every transaction must be processed correctly and without duplication.

We will explore why duplicate messages are a fact of life in distributed systems and then analyze several practical patterns for implementing idempotent consumers, weighing the trade-offs of each.

1. The Inevitability of Duplicate Messages

In an ideal world, a message broker would deliver every message exactly once. However, in the face of network partitions, service crashes, and broker failures, most high-throughput systems guarantee at-least-once delivery. This is a pragmatic choice that prioritizes durability over preventing duplicates.

Let's examine the classic failure scenario that leads to duplicate processing.

Handling duplicate messages using the Idempotent ...

The article 'Handling duplicate messages using the Idempotent...' clearly explains the common failure modes that lead to duplicate message delivery. It sets the stage perfectly for why we need idempotent consumers.

Please read the section 'Why can duplicate messages occur?'. Pay close attention to the pseudo-code showing the sequence of operations and the description of how a crash between committing a database transaction and acknowledging the message leads to redelivery.

As the article highlights, the core issue lies in the lack of a distributed atomic transaction that spans both your database and the message broker. A failure after the database commit but before the message acknowledgment leaves the system in an inconsistent state, forcing the broker to redeliver the message.

It's also important to note that even advanced features like Kafka's exactly-once semantics (EOS) typically apply to operations within the Kafka ecosystem (e.g., a Kafka Streams application reading from one topic and writing to another). When your consumer's side effect is an update to an external system like PostgreSQL or MongoDB, you are responsible for ensuring end-to-end exactly-once processing.

2. The Idempotent Consumer Pattern

Since we cannot always prevent duplicate delivery, we must design our consumers to handle them gracefully. This is achieved by making the message handler idempotent.

An operation is idempotent if the result of performing it once is the same as the result of performing it multiple times.

Some operations are naturally idempotent. For example, setting a user's status to ACTIVE can be done multiple times with no adverse effect. However, many business operations are not, such as debiting an account balance.

Handling duplicate messages using the Idempotent ...

Let's revisit the same article to solidify the definition of idempotency in this context.

Read the brief section 'Idempotency is important'. The examples clearly distinguish between naturally idempotent and non-idempotent handlers.

To make a non-idempotent operation safe to retry, we use the Idempotent Consumer pattern. The central idea is to track the IDs of messages that have already been processed and discard any duplicates upon arrival.

Let's explore the primary strategies for implementing this pattern.

Idempotent Consumer: Exactly Once Semantics

The article 'Exactly Once Semantics Using the Idempotent Consumer Pattern' provides an excellent, structured overview of several implementation strategies and their trade-offs.

Please read the sections from 'Basic Design...' through 'Final Thoughts'. This will walk you through the problem and three key solutions: using a separate table, using external storage, and using upsert techniques. Focus on the sequence of operations for each pattern and the comparison between them.

Based on the reading and your experience, let's dissect these strategies.

Strategy 1: Tracking IDs in a Separate Database Table

This is arguably the most robust and general-purpose approach when using a relational database that supports ACID transactions.

The Flow:

  1. The consumer receives a message.
  2. It begins a database transaction.
  3. Within the transaction, it first inserts the unique message ID into a dedicated processed_messages table. This table should have a primary key constraint on the message ID.
  4. If the insert fails due to a primary key violation, it means the message is a duplicate. The consumer can safely abort the transaction, acknowledge the message to the broker, and discard it.
  5. If the insert succeeds, the consumer proceeds to execute its business logic (e.g., updating the orders and line_items tables) within the same transaction.
  6. The entire transaction is committed.
  7. Finally, the consumer acknowledges the message to the broker.

Analysis:

  • Pros: Atomicity is the key benefit. The database transaction guarantees that either both the business logic and the message ID tracking succeed, or neither does. This provides a strong correctness guarantee.
  • Cons:
    • Performance overhead: It requires an additional write and index lookup for every message.
    • Table growth: The processed_messages table will grow indefinitely. A garbage collection strategy (e.g., a background job deleting records older than a reasonable time window for duplicates) is essential.
    • Dependency: Relies on a database that supports multi-table transactions.

Strategy 2: Tracking IDs in an External Key-Value Store

This pattern offloads the tracking mechanism to a fast, external store like Redis.

The Flow:

  1. The consumer receives a message.
  2. It queries Redis to check if the message ID exists.
  3. If the ID exists, the message is a duplicate. The consumer acknowledges it to the broker and discards it.
  4. If the ID does not exist, the consumer executes its business logic against the primary database.
  5. After the database write is successful, the consumer writes the message ID to Redis (often with a TTL to manage growth).
  6. The consumer acknowledges the message to the broker.

Analysis:

  • Pros: Fast lookups in Redis can reduce the latency of the check. It decouples the idempotency mechanism from the primary database schema.
  • Cons: Lack of atomicity. This is the critical flaw. There is a window of failure between the database commit (Step 4) and the Redis write (Step 5). If the service crashes here, the message will be redelivered and reprocessed because its ID was never recorded in Redis. This violates the exactly-once processing guarantee. While you could attempt to mitigate this with complex two-phase commit protocols, the complexity and performance penalties often outweigh the benefits.

Strategy 3: Using Database Upsert Techniques

This approach leverages the database's native capabilities to combine insertion and update logic into a single atomic operation.

The Flow:

  1. The consumer receives a message.
  2. It constructs a single UPSERT statement. In PostgreSQL, this is INSERT ... ON CONFLICT DO UPDATE.
  3. The ON CONFLICT clause targets a unique identifier from the event (e.g., order_id).
  4. The database executes this operation atomically.
  5. The consumer acknowledges the message to the broker.

Analysis:

  • Pros: Highly efficient and atomic. It avoids the need for a separate tracking table and multiple statements within a transaction.
  • Cons: It's not a general-purpose solution. It works best when the event represents the entire state of an entity or when the business logic can be expressed as a simple update. It is not suitable for operations that are not naturally idempotent, like balance = balance - amount.

Strategy 4: Embedding Tracking in the Business Entity

This is a powerful pattern, especially for NoSQL databases that may lack robust multi-document transaction support. It's detailed well in the first resource we reviewed (4a8c6, part 4).

The Flow:

  1. The business entity (e.g., an Order document in MongoDB) contains an attribute to track processed events, such as a set of processed_event_ids or a last_processed_version number for a given event source.
  2. The consumer receives a message.
  3. It performs a single conditional update operation on the business entity.
  4. This update atomically:
    a. Checks if the event ID is already in the processed_event_ids set (or if the event version is lower than last_processed_version).
    b. If the check passes (i.e., it's a new event), it applies the business logic changes and adds the new event ID to the set.
  5. If the conditional update fails because the event has already been processed, the operation does nothing.

Analysis:

  • Pros: Atomicity is achieved at the document level via the database's conditional update features. It co-locates the tracking data with the business data, which can be convenient.
  • Cons: It can increase the complexity of the business document. The conditional update logic can be database-specific and intricate. It is only applicable when all changes for an event are scoped to a single document/entity.

Conclusion

We've established that to build reliable distributed systems on top of at-least-once message delivery, implementing idempotent consumers is essential. This moves the challenge from preventing duplicate delivery to gracefully handling duplicate messages at the application layer.

Key Takeaways:

  • Exactly-once processing is a practical goal achieved by combining at-least-once delivery with an idempotent consumer.
  • The core of the Idempotent Consumer pattern is to track processed message IDs to detect and discard duplicates.
  • The choice of implementation strategy involves critical trade-offs:
    • Separate Tracking Table (in DB): Offers the strongest atomicity and is a great general-purpose solution for relational databases.
    • External Store (Redis): Fast but introduces a critical atomicity gap, making it risky for systems requiring strong correctness.
    • Upsert Techniques: Highly efficient and atomic but only applicable when the business logic can be modeled as an upsert.
    • Embedded Tracking (in Entity): An effective pattern for NoSQL databases that leverages conditional updates for single-document atomicity.
  • In your work with high-load payment and trading systems, the correctness guarantees provided by a transactional tracking table (Strategy 1) are often the preferred choice, as data integrity is paramount.

Preview of the Next Lesson:

Now that we have a robust mechanism for processing events, we must consider another reality of long-lived systems: change. Events, like APIs, evolve. In our next lesson, we will "Outline a strategy for non-breaking event schema evolution," exploring how to modify the structure of your events over time without breaking existing consumers or halting the system.

Can't find a good explanation? Sign up and we'll make it for you

Sign up