6 min read

A Detailed Guide to Idempotency, Delivery Semantics, and Deduplication

A Detailed Guide to Idempotency, Delivery Semantics, and Deduplication
Photo by Brett Jordan / Unsplash

1. Delivery Semantics and the Fallacy of Exactly-Once Delivery

In distributed systems design, network partitions, transient hardware faults, and process crashes make state consistency challenging. When node A sends an RPC or message to node B over an untrusted network, three delivery semantics are theoretically possible: at-most-once, at-least-once, and exactly-once. In practice, achieving true end-to-end exactly-once delivery purely at the transport layer is mathematically impossible according to the Two Generals' Problem and the FLP Impossibility Result. Transport protocols like TCP or messaging layers like Apache Kafka guarantee delivery across boundaries only by acknowledging state changes. If an acknowledgment (ACK) is dropped due to network jitter or process crashes, the sender has no choice but to retry, inherently degrading at-most-once processing into at-least-once processing.

To provide end-to-end processing guarantees, distributed infrastructure relies on at-least-once transport coupled with application-level idempotency. Idempotency ensures that executing an operation multiple times with identical parameters yields the exact state outcome on the target system as a single execution. Under high-throughput workloads, achieving idempotency requires precise state management, distributed concurrency control, and deterministic request keying.

2. Architectural Patterns for Idempotent Processing

Implementing idempotency at scale requires decoupling state execution from network transport. Modern distributed architectures rely on three core patterns: Unique Key Enforcement, Atomic Deduplication Stores, and the Transactional Outbox Pattern.

2.1 Relational Unique Key Constraints and Conditional Writes

The simplest and most resilient mechanism for enforcing idempotency is pushing the boundary of uniqueness down to the relational database layer. When processing an inbound payload, the service extracts an idempotency key (typically a UUIDv4 or deterministic hash of transaction parameters) and executes an atomic insert within an ACID transaction boundary.

In PostgreSQL, this is expressed using ON CONFLICT DO NOTHING or ON CONFLICT DO UPDATE. If a duplicate request attempts to insert the same key concurrently, the database engine enforces serialization locks at the tuple level, rejecting or ignoring duplicate operations while preserving isolation.

2.2 Distributed Locks and Atomic KV Stores

For non-relational datastores or high-throughput stream processing pipelines (e.g., handling 100k+ events/sec), relying on relational database locks introduces unacceptable connection pool exhaustion and I/O bottlenecks. In these architectures, distributed key-value stores like Redis or AWS DynamoDB act as explicit deduplication gateways.

Using Redis, an atomic lock-and-set operation can be implemented via SET key value NX PX expiration_time. If the key already exists, the worker immediately short-circuits execution, returning the cached result of the original execution. However, engineers must carefully account for lock expiration: if a processing worker encounters an unexpected garbage collection pause or long I/O delay, the lock TTL may expire prematurely, allowing a concurrent retry to execute in parallel (violating isolation).

2.3 The Transactional Outbox Pattern

In microservice architectures, an operation often requires updating a local database and publishing a downstream event via gRPC or Kafka. Performing these two actions non-atomically creates distributed inconsistency: if the database commit succeeds but the message broker publish fails (or vice versa), the system enters an indeterminate state.

The Transactional Outbox Pattern solves this by persisting both the domain state change and an outbox event record into the same local relational database within a single local transaction. A separate asynchronous relay worker tailing the write-ahead log (WAL) or polling the outbox table reads pending events, broadcasts them to Kafka with at-least-once semantics, and marks them as processed upon receiving an ACK. Downstream consumers then use deduplication locks to maintain overall system convergence.

3. Production-Grade Implementation: Idempotent Event Consumer

Below is a production-grade Go implementation of an idempotent message consumer utilizing PostgreSQL for transactional state and Redis for distributed deduplication key caching with lock renewal capabilities.

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/go-redis/redis/v8"
	_ "github.com/lib/pq"
)

type IdempotentProcessor struct {
	db    *sql.DB
	rdb   *redis.Client
	ttl   time.Duration
}

func NewIdempotentProcessor(db *sql.DB, rdb *redis.Client, lockTTL time.Duration) *IdempotentProcessor {
	return &IdempotentProcessor{
		db:    db,
		rdb:   rdb,
		ttl:   lockTTL,
	}
}

func (p *IdempotentProcessor) ProcessPayment(ctx context.Context, idempotencyKey string, accountID string, amount float64) error {
	redisLockKey := fmt.Sprintf("lock:idempotency:%s", idempotencyKey)
	
	// Attempt atomic acquire of idempotency key via Redis SETNX
	setSuccess, err := p.rdb.SetNX(ctx, redisLockKey, "PROCESSING", p.ttl).Result()
	if err != nil {
		return fmt.Errorf("failed to reach deduplication cache: %w", err)
	}
	if !setSuccess {
		// Duplicate request in-flight or recently processed
		return errors.New("concurrent or duplicate request detected")
	}

	// Ensure cleanup on failures
	defer func() {
		// In production, update status to COMPLETED with long-lived TTL
		_ = p.rdb.Expire(ctx, redisLockKey, 24*time.Hour)
	}()

	// Begin relational transaction for atomic state updates
	tx, err := p.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer tx.Rollback()

	// Check persistent database storage for executed key
	var existingID string
	checkQuery := `SELECT id FROM processed_transactions WHERE idempotency_key = $1`
	err = tx.QueryRowContext(ctx, checkQuery, idempotencyKey).Scan(&existingID)
	if err == nil {
		// Transaction already applied previously
		return nil
	} else if !errors.Is(err, sql.ErrNoRows) {
		return fmt.Errorf("error querying processed transactions: %w", err)
	}

	// Apply financial mutations
	updateQuery := `UPDATE accounts SET balance = balance + $1 WHERE id = $2`
	_, err = tx.ExecContext(ctx, updateQuery, amount, accountID)
	if err != nil {
		return fmt.Errorf("failed to mutate account balance: %w", err)
	}

	// Record processing history to guarantee relational unique constraint
	recordQuery := `INSERT INTO processed_transactions (idempotency_key, account_id, amount, created_at) VALUES ($1, $2, $3, NOW())`
	_, err = tx.ExecContext(ctx, recordQuery, idempotencyKey, accountID, amount)
	if err != nil {
		return fmt.Errorf("failed to record transaction key: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("transaction commit failure: %w", err)
	}

	return nil
}

4. Handling Edge Cases: Out-of-Order Execution and Key Expiration

While basic deduplication handles straightforward re-deliveries, complex edge cases emerge under real-world network turbulence. Systems must maintain deterministic consistency under out-of-order execution, sliding time windows, and cache evictions.

4.1 Out-of-Order Execution and Sequence Tokens

When messages are published to distributed streaming platforms like Apache Kafka, messages partitioned across different topics or keys may arrive at consumers out of order. If event E2 (e.g., Update Order Status to Delivered) arrives before event E1 (e.g., Create Order), applying E2 directly will corrupt domain state or throw foreign key violations.

To resolve out-of-order delivery, application events must carry monotonically increasing sequence numbers or vector clocks. Systems utilize state machine validation patterns, where state transitions are accepted only if event.sequence_number == current_state.sequence_number + 1. If a sequence gap is detected, the out-of-order event is either placed into a staging buffer or rejected back to the queue with a dead-letter delay retry strategy.

4.2 Managing Key Expiration and Storage TTL Trade-Offs

Idempotency records cannot be preserved in fast-access stores like Redis indefinitely due to RAM constraints. Selecting key time-to-live (TTL) durations involves balancing storage costs against retry window assumptions.

  • Short TTLs (< 1 Hour): Minimize memory footprint but risk duplicate execution if client retries occur after long network outages or manual support interventions.
  • Long TTLs (> 30 Days): Provide high safety margins for enterprise client integration, requiring cold-storage offloading (e.g., partitioning historical keys into S3/Parquet or Cassandra columnar tables).

High-scale infrastructure often deploys probabilistic filter structures, such as Bloom Filters or Cuckoo Filters, as a first-pass check. A Bloom Filter can rapidly verify if an idempotency key has definitely not been seen before in $O(1)$ time with zero disk reads, preventing expensive primary key lock lookups in relational databases for new requests.

5. Infrastructure Trade-Offs and Architectural Considerations

Implementing bulletproof idempotency layers involves explicit system design trade-offs regarding latency, write amplification, and failure isolation boundaries.

The primary cost of idempotency is added latency. Every inbound API call or stream message must query a central deduplication registry (Redis, DynamoDB, or PostgreSQL) before initiating actual business logic. In ultra-low-latency workloads (< 5ms SLAs), introducing a remote network hop for key validation doubles round-trip times. Engineers must evaluate whether local microservice memory caching (with short L1 TTLs) combined with optimistic concurrency control at the database layer offers sufficient isolation without overloading centralized caches.

Furthermore, failure modes within deduplication datastores must be isolated. If an external Redis cluster hosting idempotency locks experiences a failover or memory exhaustion, services must choose between failing closed (rejecting all retries to guarantee strict safety) or failing open (proceeding with execution, accepting potential duplicates in exchange for high availability). The correct path depends heavily on domain semantics: financial billing systems must fail closed, whereas metric streaming and telemetry ingestion pipelines should fail open.