Tag: Transactional Outbox

  • Dead Letter Queue + Idempotency: Exactly-Once on Hazelcast

    Dead Letter Queue + Idempotency: Exactly-Once on Hazelcast

    Part 9 in the “Building Event-Driven Microservices with Hazelcast” series


    Over the past two articles, we built resilience into both sides of our saga communication. Part 7 added circuit breakers and retry to protect saga listeners against transient failures during event consumption. Part 8 added the transactional outbox to guarantee event delivery from producer to shared cluster.

    Two gaps remain.

    First: what happens when an event fails processing permanently? The circuit breaker exhausts retries. NonRetryableException gets thrown. The event is gone — all that survives is a log message. There’s no way to inspect what failed, understand why, or retry it later when someone fixes the underlying problem.

    Second: what happens when the outbox delivers an event twice? At-least-once delivery means duplicates are possible. Without protection, the Inventory Service might reserve stock twice for the same order. The Payment Service might charge the customer twice.

    This article covers two complementary patterns that close these gaps. The dead letter queue captures events that fail consumer-side processing, giving operators a way to inspect, replay, and discard them. The idempotency guard ensures each event is processed exactly once, even if delivered multiple times.

    Put them together with the outbox and you get effectively-once semantics — at-least-once delivery on the producer side, exactly-once processing on the consumer side. That’s the gold standard for event-driven systems.


    Part 1: Dead Letter Queue

    The Problem

    Consider this failure sequence in the Inventory Service’s saga listener:

    Permanent failure without a dead letter queue: an OrderCreated event arrives, the InventorySagaListener picks it up and calls executeWithResilience, a non-retryable InsufficientStockException is thrown, the circuit breaker records the failure, a ResilienceException propagates, the error is logged, and the event is gone with only a log line surviving

    That log message? That’s all you’ve got. In production, recovering from this means searching logs for the event ID, reconstructing the event payload from other sources, manually fixing whatever went wrong, and then figuring out how to re-trigger the saga step.

    A dead letter queue captures the failed event — payload, failure reason, saga context, source service, everything — in a durable store that you can actually query and act on.

    The DeadLetterEntry

    Each DLQ entry preserves the full failure context:

    public class DeadLetterEntry {
    
        private String dlqEntryId;       // UUID — unique DLQ identifier
        private String originalEventId;  // The event that failed
        private String eventType;        // "OrderCreated", "StockReserved", etc.
        private String topicName;        // The ITopic where the event was published
        private GenericRecord eventRecord; // The complete event payload for replay
        private String failureReason;    // Why processing failed
        private Instant failureTimestamp; // When the failure occurred
        private String sourceService;    // Which service failed ("inventory-service")
        private String sagaId;           // Saga context for tracing
        private String correlationId;    // Correlation context for tracing
        private int replayCount;         // How many times this entry has been replayed
        private Status status;           // PENDING, REPLAYED, or DISCARDED
    
        public enum Status {
            PENDING,    // Awaiting review or replay
            REPLAYED,   // Re-published to original topic
            DISCARDED   // Manually discarded by administrator
        }
    }
    

    Construction at the failure site uses a builder:

    DeadLetterEntry.builder()
        .originalEventId(eventId)
        .eventType(record.getString("eventType"))
        .topicName("OrderCreated")
        .eventRecord(record)
        .failureReason(error.getMessage())
        .sourceService("inventory-service")
        .sagaId(record.getString("sagaId"))
        .correlationId(record.getString("correlationId"))
        .build();
    

    The eventRecord field is the important one — it holds the complete GenericRecord that was published to the ITopic. When you replay the entry, this exact record gets re-published to the original topic, picking the saga back up where it left off.

    The DeadLetterQueueOperations Interface

    Same interface-extraction pattern we used for ResilientOperations and ServiceClientOperations (Java 25 Mockito can’t mock concrete classes, so we keep extracting interfaces — it’s becoming a running theme):

    public interface DeadLetterQueueOperations {
    
        void add(DeadLetterEntry entry);
    
        List<DeadLetterEntry> list(int limit);
    
        DeadLetterEntry getEntry(String dlqEntryId);
    
        void replay(String dlqEntryId);
    
        void discard(String dlqEntryId);
    
        long count();
    }
    

    HazelcastDeadLetterQueue: IMap-Backed Storage

    The implementation stores DLQ entries as Compact-serialized GenericRecords in a Hazelcast IMap — same pattern as the HazelcastOutboxStore:

    public class HazelcastDeadLetterQueue implements DeadLetterQueueOperations {
    
        private static final String SCHEMA_NAME = "DeadLetterEntry";
        private final HazelcastInstance hazelcast;
        private final IMap<String, GenericRecord> dlqMap;
        private final DeadLetterQueueProperties properties;
        private final MeterRegistry meterRegistry;
    
        public HazelcastDeadLetterQueue(HazelcastInstance hazelcast,
                                         DeadLetterQueueProperties properties,
                                         MeterRegistry meterRegistry) {
            this.hazelcast = hazelcast;
            this.dlqMap = hazelcast.getMap(properties.getMapName());
            // ...
        }
    }
    

    The DLQ map lives on the shared cluster (falling back to the embedded instance if there’s no shared cluster), so it’s accessible from any service’s admin endpoint. You don’t need to know which service failed — query the DLQ from anywhere and you’ll see everything.

    POJO-to-GenericRecord Conversion

    Like the outbox store, conversion happens at the boundary:

    static GenericRecord toRecord(final DeadLetterEntry entry) {
        return GenericRecordBuilder.compact(SCHEMA_NAME)
                .setString("dlqEntryId", entry.getDlqEntryId())
                .setString("originalEventId", entry.getOriginalEventId())
                .setString("eventType", entry.getEventType())
                .setString("topicName", entry.getTopicName())
                .setGenericRecord("eventRecord", entry.getEventRecord())
                .setString("failureReason", entry.getFailureReason())
                .setInt64("failureTimestamp", entry.getFailureTimestamp().toEpochMilli())
                .setString("sourceService", entry.getSourceService())
                .setString("sagaId", entry.getSagaId())
                .setString("correlationId", entry.getCorrelationId())
                .setInt32("replayCount", entry.getReplayCount())
                .setString("status", entry.getStatus().name())
                .build();
    }
    

    Note setGenericRecord(“eventRecord”, …) — Compact serialization handles nested GenericRecords natively. The full event payload comes along for the ride without any special serialization work on our part.

    Replay

    This is where the DLQ earns its keep. Once you’ve figured out what went wrong and fixed it — restocked inventory, restarted a flaky service, whatever — you replay the entry:

    @Override
    public void replay(final String dlqEntryId) {
        final GenericRecord record = dlqMap.get(dlqEntryId);
        if (record == null) {
            throw new IllegalArgumentException("DLQ entry not found: " + dlqEntryId);
        }
    
        final DeadLetterEntry entry = fromRecord(record);
    
        if (entry.getStatus() != DeadLetterEntry.Status.PENDING) {
            throw new IllegalStateException(
                    "Cannot replay entry in status " + entry.getStatus());
        }
        if (entry.getReplayCount() >= properties.getMaxReplayAttempts()) {
            throw new IllegalStateException(
                    "Max replay attempts (" + properties.getMaxReplayAttempts() + ") exceeded");
        }
    
        // Re-publish to the original topic
        final GenericRecord eventRecord = entry.getEventRecord();
        if (eventRecord != null && entry.getTopicName() != null) {
            final ITopic<GenericRecord> topic = hazelcast.getTopic(entry.getTopicName());
            topic.publish(eventRecord);
        }
    
        // Update entry status
        entry.setReplayCount(entry.getReplayCount() + 1);
        entry.setStatus(DeadLetterEntry.Status.REPLAYED);
        dlqMap.set(dlqEntryId, toRecord(entry));
    
        meterRegistry.counter("dlq.entries.replayed").increment();
    }
    

    A few safety guards here. Only PENDING entries can be replayed — you can’t accidentally replay something that was already replayed or discarded. There’s a configurable max replay count (default 3) to prevent infinite replay loops if the underlying issue isn’t actually fixed. And if the eventRecord is somehow null (shouldn’t happen, but defensive coding), the status updates without attempting a publish.

    Monitoring Queue Depth

    The count() method uses a Hazelcast predicate to count only PENDING entries:

    @Override
    public long count() {
        final Collection<GenericRecord> pending = dlqMap.values(
                Predicates.equal("status", DeadLetterEntry.Status.PENDING.name()));
        return pending.size();
    }
    

    A DLQ count above zero for more than a few minutes is a flag that something needs attention. Wire this to an alert and you’ll know about failed events before anyone files a ticket.

    Admin REST Endpoints

    The DeadLetterQueueController exposes the DLQ through REST:

    @RestController
    @RequestMapping("/api/admin/dlq")
    @Tag(name = "Dead Letter Queue")
    public class DeadLetterQueueController {
    
        @GetMapping
        public ResponseEntity<List<DeadLetterEntry>> list(
                @RequestParam(defaultValue = "20") int limit) {
            return ResponseEntity.ok(deadLetterQueue.list(limit));
        }
    
        @GetMapping("/count")
        public ResponseEntity<Map<String, Long>> count() {
            return ResponseEntity.ok(Map.of("count", deadLetterQueue.count()));
        }
    
        @GetMapping("/{id}")
        public ResponseEntity<DeadLetterEntry> getEntry(@PathVariable String id) { ... }
    
        @PostMapping("/{id}/replay")
        public ResponseEntity<Map<String, String>> replay(@PathVariable String id) { ... }
    
        @DeleteMapping("/{id}")
        public ResponseEntity<Map<String, String>> discard(@PathVariable String id) { ... }
    }
    

    A typical investigation looks like this:

    # How many pending entries?
    curl http://localhost:8082/api/admin/dlq/count
    # {"count": 2}
    
    # What are they?
    curl http://localhost:8082/api/admin/dlq
    # [{"dlqEntryId":"abc-123", "originalEventId":"evt-456",
    #   "eventType":"OrderCreated", "failureReason":"Insufficient stock for product PROD-789",
    #   "sourceService":"inventory-service", "status":"PENDING", ...}]
    
    # Get the full details on one
    curl http://localhost:8082/api/admin/dlq/abc-123
    
    # Fix the problem (restock inventory), then replay
    curl -X POST http://localhost:8082/api/admin/dlq/abc-123/replay
    # {"status":"replayed", "dlqEntryId":"abc-123"}
    
    # Or discard if the saga already timed out and compensation ran
    curl -X DELETE http://localhost:8082/api/admin/dlq/abc-123
    # {"status":"discarded", "dlqEntryId":"abc-123"}
    

    Integration with Saga Listeners

    Each saga listener injects the DLQ as an optional dependency:

    @Autowired(required = false)
    public void setDeadLetterQueue(DeadLetterQueueOperations deadLetterQueue) {
        this.deadLetterQueue = deadLetterQueue;
    }
    

    Failed events get routed to the DLQ in the error handler:

    private void sendToDeadLetterQueue(GenericRecord record, String topicName, Throwable error) {
        String eventId = record.getString("eventId");
        if (deadLetterQueue != null) {
            try {
                deadLetterQueue.add(DeadLetterEntry.builder()
                        .originalEventId(eventId)
                        .eventType(record.getString("eventType"))
                        .topicName(topicName)
                        .eventRecord(record)
                        .failureReason(error.getMessage())
                        .sourceService("inventory-service")
                        .sagaId(record.getString("sagaId"))
                        .correlationId(record.getString("correlationId"))
                        .build());
                logger.warn("Event {} sent to DLQ after failure: {}", eventId, error.getMessage());
            } catch (Exception dlqError) {
                logger.error("Failed to send event {} to DLQ: {}", eventId, dlqError.getMessage());
            }
        } else {
            // Fallback: existing behavior (log only)
            if (error instanceof ResilienceException) {
                logger.warn("Circuit breaker open, saga step deferred: eventId={}", eventId);
            } else {
                logger.error("Failed to process event: {}", eventId, error);
            }
        }
    }
    

    The try/catch around deadLetterQueue.add() is defensive. If the DLQ itself fails — shared cluster unreachable, say — we fall back to logging. The DLQ is best-effort, not a hard requirement. Losing an event and failing to capture it in the DLQ would be truly unlucky, but it shouldn’t bring the service down.


    Part 2: Idempotency Guard

    The Problem

    The transactional outbox gives us at-least-once delivery. Combined with ITopic’s own delivery behavior (listeners that reconnect after a brief disconnection may receive messages again), the same event can arrive at a consumer more than once:

    Duplicate delivery sequence: the OutboxPublisher publishes OrderCreated to the shared cluster, which forwards it to the Inventory Listener; the markDelivered call times out, so on the next poll cycle the publisher re-publishes the same event, and without protection the Inventory Listener performs a double stock reservation

    Without protection, inventory gets reserved twice. The customer gets charged twice. The order gets confirmed twice. Nobody wants that.

    Atomic Check-and-Claim

    The fix is Hazelcast’s putIfAbsent — an atomic, cluster-wide check-and-set that ensures each event ID gets processed exactly once:

    public class HazelcastIdempotencyGuard implements IdempotencyGuard {
    
        private final IMap<String, Long> processedEventsMap;
        private final long ttlMillis;
        private final MeterRegistry meterRegistry;
    
        public HazelcastIdempotencyGuard(HazelcastInstance hazelcast,
                                          IdempotencyProperties properties,
                                          MeterRegistry meterRegistry) {
            this.processedEventsMap = hazelcast.getMap(properties.getMapName());
            this.ttlMillis = properties.getTtl().toMillis();
            this.meterRegistry = meterRegistry;
        }
    
        @Override
        public boolean tryProcess(final String eventId) {
            Long previous = processedEventsMap.putIfAbsent(
                    eventId, System.currentTimeMillis(), ttlMillis, TimeUnit.MILLISECONDS);
    
            boolean firstTime = (previous == null);
            meterRegistry.counter("idempotency.checks",
                    "result", firstTime ? "miss" : "hit").increment();
    
            if (!firstTime) {
                logger.debug("Duplicate event detected: eventId={}", eventId);
            }
    
            return firstTime;
        }
    }
    

    The interface is one method:

    public interface IdempotencyGuard {
        boolean tryProcess(String eventId);
    }
    

    Returns true if this is the first time the event ID has been seen — go ahead and process it. Returns false if someone already claimed it — skip.

    How putIfAbsent Works

    IMap.putIfAbsent(key, value, ttl, timeUnit) is atomic. If the key doesn’t exist, it inserts the pair and returns null. If it does exist, it returns the existing value and does nothing. This atomicity holds across cluster members — two listeners on different nodes processing the same event simultaneously will never both get null. Exactly one wins, the other backs off.

    TTL: Forgetting Old Events

    The putIfAbsent includes a TTL (default: 1 hour). After that, the event ID is removed from the map, and the same event could theoretically be reprocessed if it somehow arrived again.

    Why an hour? It’s a memory management decision. Without a TTL, the processed events map grows forever. With a 1-hour window, we hold at most an hour’s worth of event IDs, which is bounded and predictable. Since our outbox publisher has a 1-second poll interval with 5 retries, duplicates arrive within seconds — an hour of margin is more than sufficient.

    Integration with Saga Listeners

    Each saga listener checks the guard at the top of its message handler:

    class OrderCreatedListener implements MessageListener<GenericRecord> {
    
        @Override
        public void onMessage(Message<GenericRecord> message) {
            GenericRecord record = message.getMessageObject();
    
            String eventId = record.getString("eventId");
            if (idempotencyGuard != null && eventId != null
                    && !idempotencyGuard.tryProcess(eventId)) {
                logger.debug("Duplicate event {} already processed, skipping", eventId);
                return;
            }
    
            // ... proceed with normal processing
        }
    }
    

    Three null checks for graceful degradation: if idempotency isn’t configured, process everything (no deduplication). If the event doesn’t have an ID, skip the check. If tryProcess() returns false, it’s a duplicate — drop it silently.

    The Processed Events Map

    The map lives on the shared cluster, so deduplication works across all service instances:

    Key Value TTL
    evt-abc-123 1738000000000 (timestamp) 1 hour
    evt-def-456 1738000001000 1 hour
    evt-ghi-789 1738000002000 1 hour

    The value — a processing timestamp — is purely informational. Only the key’s presence or absence matters for deduplication. But the timestamp is handy for debugging: it tells you exactly when an event was first processed.


    How the Three Patterns Work Together

    The outbox, DLQ, and idempotency guard form a complete reliability pipeline:

    The three patterns working together: on the producer side the EventSourcingController writes to the OUTBOX IMap, the OutboxPublisher polls and publishes to the shared ITopic, then marks the entry delivered; on the consumer side the saga listener checks the IdempotencyGuard (duplicates are skipped), runs executeWithResilience, and on failure routes the event to the framework_DLQ IMap for admin replay or discard

    Let’s walk through what happens when things go wrong.

    The OrderCreated event comes out of the Jet pipeline and gets written to the outbox. The OutboxPublisher picks it up, publishes to the shared cluster’s OrderCreated ITopic, and tries to mark it DELIVERED. But the markDelivered call times out. Next poll cycle, the publisher re-publishes the same event. Now it’s been delivered twice.

    Over on the consumer side, the Inventory Service’s OrderCreatedListener receives both copies. The first call to idempotencyGuard.tryProcess(“evt-123”) returns true — process it. The second call returns false — duplicate, skip it. Only one stock reservation happens.

    But that first delivery hits a problem: the product is out of stock. InsufficientStockException is non-retryable. The circuit breaker records the failure, ResilienceException propagates up to whenComplete(), and sendToDeadLetterQueue() captures everything — the full event payload, the failure reason, the saga ID, the source service. It’s all sitting in the framework_DLQ IMap, waiting.

    An operator (or an LLM, as we’ll see in a moment) checks the DLQ, sees the pending entry, restocks the product, and replays the event. The OrderCreated record gets re-published to the ITopic, the saga picks up, and the order completes.

    One wrinkle: the replayed event carries the same eventId as the original. If the 1-hour idempotency TTL hasn’t expired yet, the guard will block it as a duplicate. In practice this isn’t an issue — by the time you’ve investigated the failure, diagnosed the root cause, and fixed it, an hour has usually passed. It’s a deliberate trade-off: short-window deduplication versus immediate replay. We chose deduplication.


    Configuration Reference

    Dead Letter Queue: framework.dlq.*

    Property Default Description
    enabled true Master toggle
    map-name framework_DLQ IMap name on shared cluster
    max-replay-attempts 3 Maximum replays before permanent block
    entry-ttl 168h 7-day retention for DLQ entries

    Idempotency Guard: framework.idempotency.*

    Property Default Description
    enabled true Master toggle
    map-name framework_PROCESSED_EVENTS IMap name on shared cluster
    ttl 1h How long to remember processed event IDs

    Metrics

    Metric Type Description
    dlq.entries.added Counter Events added to the DLQ
    dlq.entries.replayed Counter Events replayed from the DLQ
    dlq.entries.discarded Counter Events discarded from the DLQ
    idempotency.checks Counter (tagged: result=hit|miss) Deduplication checks

    The Complete Resilience Stack

    Across Parts 7, 8, and 9, we’ve built five interlocking patterns:

    Pattern Layer Purpose Protects Against
    Circuit Breaker Consumer Automatic service isolation Cascade failures
    Retry + Backoff Consumer Transient failure recovery Network blips, brief outages
    Transactional Outbox Producer Guaranteed delivery Shared cluster unavailability
    Dead Letter Queue Consumer Failure capture and replay Permanent processing failures
    Idempotency Guard Consumer Exactly-once processing Duplicate delivery

    They’re all optional — enabled by default, disabled with a single property toggle. They’re all auto-configured by Spring Boot. They all expose Micrometer metrics. And when disabled, the framework falls back to its previous behavior without breaking anything.

    Three articles ago, we had a fire-and-forget event pipeline where a network blip could lose an event forever. Now we have guaranteed delivery, deduplication, failure capture, and replay. Same pipeline, five patterns later.


    Try It Yourself

    The demo script includes a complete DLQ investigation scenario — fault injection, failure capture, investigation, and replay — in 11 guided steps:

    ./scripts/demo-scenarios.sh 7
    

    That’s the curl-based version. No LLM required.

    The AI-Powered Version

    This is more fun. Connect the MCP server from Part 6 to your LLM client — Claude Desktop, Claude Code, ChatGPT, whatever you’ve got — and try this prompt:

    “Run the DLQ investigation demo — inject a failure, place an order, and show me what’s in the dead letter queue.”

    The LLM calls runDemo to set up the scenario, then listDlqEntries and inspectDlqEntry to investigate. It tells you what happened — which event failed, at which service, and why — and suggests a fix. You say “replay it.” It calls replayDlqEntry, the saga completes, and you’ve just done incident response through a conversation.

    No curl commands. No JSON parsing. No copy-pasting UUIDs. The LLM handles the plumbing while you make the decisions.

    If the LLM already has context from earlier in the session, a shorter version works:

    “Run the dlq_investigation demo scenario and tell me what you find.”

    Next up: Choreography vs Orchestration: Two Saga Patterns

    Previous: Hazelcast Transactional Outbox: Guaranteed Delivery

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.
  • Hazelcast Transactional Outbox: Guaranteed Delivery

    Hazelcast Transactional Outbox: Guaranteed Delivery

    Part 8 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    In Part 7, we added circuit breakers and retry to protect saga listeners from transient failures on the consumer side. That covers what happens when a service receives an event and can’t process it. But we haven’t talked about what happens when the event never leaves the building.

    Quick refresher on our dual-instance architecture: each service runs an embedded Hazelcast instance for local Jet pipeline processing and a client connected to the shared cluster for cross-service ITopic communication. After the pipeline processes an event, the EventSourcingController republishes it to the shared cluster so saga listeners in other services can react.

    That republish step? It was a fire-and-forget call:

    // The old approach — fragile
    try {
        ITopic<GenericRecord> topic = sharedHazelcast.getTopic(pending.eventType);
        topic.publish(pending.eventRecord);
    } catch (Exception e) {
        logger.warn("Failed to republish event {}: {}", pending.eventType, e.getMessage());
        // Event is permanently lost!
    }
    

    If the shared cluster is unreachable — network partition, cluster restart, someone tripping over the power cable — the event vanishes. The saga never progresses. Eventually the saga timeout detector marks it as failed, but by then the original event data is gone and there’s nothing to retry.

    The Transactional Outbox Pattern fixes this. Instead of publishing directly to the shared cluster, the controller writes the event to a local outbox — an IMap on the embedded Hazelcast instance — and a separate publisher component picks it up and delivers it. If delivery fails, the entry stays in the outbox and gets retried.


    Why Direct Publishing Fails

    The problem is fundamental. Publishing to an external system (the shared cluster) and completing a local operation (the Jet pipeline) are two separate operations that can’t be made atomic.

    Failure timeline for direct publishing — the Jet pipeline updates the local event store and materialized view, but the publish to the shared cluster ITopic fails on a network partition and the event is lost with nothing left to retry

    The event is safely stored in the local event store and materialized view, but the cross-service notification is lost. You could retry in place, but that blocks the Jet pipeline for all events. You could schedule an async retry, but if the process restarts, that retry state is gone too.

    The outbox pattern trades immediate delivery for guaranteed delivery. Write to a durable local store, deliver asynchronously, retry until it works. It’s the standard solution in event-driven architectures for good reason.


    Architecture

    Transactional outbox architecture — the EventSourcingController writes each event to a durable local OUTBOX IMap and signals the OutboxPublisher via a semaphore; the publisher claims pending entries and delivers them to the shared cluster ITopic, retrying on failure

    The outbox IMap lives on the embedded Hazelcast instance — the same instance that hosts the event store and materialized views. Writing to it is a local operation. If the embedded instance is up (and it must be, since the pipeline just ran), the outbox write succeeds.


    The OutboxEntry

    Each outbox entry captures everything needed to deliver the event later:

    public class OutboxEntry {
    
        private String eventId;          // Matches the domain event's eventId
        private String eventType;        // ITopic name (e.g., "OrderCreated")
        private GenericRecord eventRecord; // The serialized event to publish
        private int retryCount;          // Delivery attempts so far
        private Status status;           // PENDING, DELIVERED, or FAILED
        private Instant createdAt;       // When the entry was created
        private Instant lastAttemptAt;   // When the last delivery attempt occurred
        private String failureReason;    // Most recent failure message
    
        public enum Status {
            PENDING,    // Awaiting delivery
            DELIVERED,  // Successfully published to shared cluster
            FAILED      // Permanently failed after max retries
        }
    }
    

    The eventRecord field is the full GenericRecord that needs to go to the shared cluster’s ITopic — same record the Jet pipeline produces, complete with saga metadata like sagaId and correlationId.


    OutboxStore: The Interface

    Six methods covering the full lifecycle:

    public interface OutboxStore {
    
        void write(OutboxEntry entry);
    
        List<OutboxEntry> pollPending(int maxBatchSize);
    
        void markDelivered(String eventId);
    
        void markFailed(String eventId, String reason);
    
        void incrementRetryCount(String eventId, String failureReason);
    
        long pendingCount();
    }
    

    Provider-agnostic. The Hazelcast implementation uses an IMap, but the interface could just as easily sit in front of a database table.


    HazelcastOutboxStore

    The Hazelcast implementation stores entries as Compact-serialized GenericRecord values in an IMap:

    public class HazelcastOutboxStore implements OutboxStore {
    
        private static final String SCHEMA_NAME = "OutboxEntry";
        private final IMap<String, GenericRecord> outboxMap;
    
        public HazelcastOutboxStore(HazelcastInstance hazelcast, MeterRegistry meterRegistry) {
            this.outboxMap = hazelcast.getMap(DEFAULT_MAP_NAME);
        }
    }
    

    You might wonder why we’re using GenericRecord instead of storing OutboxEntry Java objects directly. The problem is that OutboxEntry has an Instant field and a nested GenericRecord — neither of which Hazelcast’s zero-config Compact serialization can handle. We’d need a custom CompactSerializer registered on every Hazelcast instance configuration. Instead, we convert at the boundary:

    static GenericRecord toRecord(final OutboxEntry entry) {
        return GenericRecordBuilder.compact(SCHEMA_NAME)
                .setString("eventId", entry.getEventId())
                .setString("eventType", entry.getEventType())
                .setGenericRecord("eventRecord", entry.getEventRecord())
                .setInt32("retryCount", entry.getRetryCount())
                .setString("status", entry.getStatus().name())
                .setInt64("createdAt", entry.getCreatedAt().toEpochMilli())
                .setNullableInt64("lastAttemptAt",
                        entry.getLastAttemptAt() != null
                                ? entry.getLastAttemptAt().toEpochMilli() : null)
                .setString("failureReason", entry.getFailureReason())
                .build();
    }
    

    A few things going on here. Instant becomes int64 epoch millis — compact, sortable, unambiguous. lastAttemptAt uses setNullableInt64 because it’s null until the first delivery attempt. The nested eventRecord uses setGenericRecord, which Compact handles natively. And status is stored as the enum name string, which makes it readable in Management Center and queryable with Predicates.equal().

    Polling uses a Hazelcast predicate to filter by status, sorted by creation time so the oldest entries are delivered first:

    @Override
    public List<OutboxEntry> pollPending(final int maxBatchSize) {
        final Collection<GenericRecord> pending = outboxMap.values(
                Predicates.equal("status", OutboxEntry.Status.PENDING.name()));
    
        return pending.stream()
                .map(HazelcastOutboxStore::fromRecord)
                .sorted(Comparator.comparing(OutboxEntry::getCreatedAt))
                .limit(maxBatchSize)
                .collect(Collectors.toList());
    }
    

    The OutboxPublisher

    The publisher bridges the outbox and the shared cluster. The obvious approach is to poll on a fixed interval — once per second, say — but that adds latency we don’t need. We know exactly when a new entry arrives.

    Event-Driven Wake-Up

    The publisher uses a Semaphore to sleep until someone signals it:

    public class OutboxPublisher {
    
        private final Semaphore wakeUp = new Semaphore(0);
    
        public void notifyNewEntry() {
            // Release at most 1 permit — avoids unbounded accumulation
            if (wakeUp.availablePermits() == 0) {
                wakeUp.release();
            }
        }
    
        public boolean waitForWork() {
            try {
                return wakeUp.tryAcquire(
                        properties.getPollInterval().toMillis(),
                        TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
    }
    

    When the EventSourcingController writes an outbox entry, it calls notifyNewEntry() right after. The publisher wakes up, claims all pending entries, delivers them. Under normal conditions, the time from event creation to shared-cluster delivery is sub-millisecond.

    The poll interval (default 1 second) is the safety net. If a signal gets missed — maybe the publisher was busy with a previous batch — the timeout ensures nothing sits around for too long.

    This is a JVM-local semaphore, not a distributed one. That’s fine. When the service scales to multiple replicas with per-service clustering (ADR 013), each replica has its own publisher. The semaphore wakes the local publisher instantly for locally-written events. Events written by other replicas get picked up within the poll interval. The actual coordination — preventing two replicas from delivering the same event — happens in claimPending() via an atomic ClaimEntryProcessor on the IMap.

    The Publish Loop

    public void publishPendingEntries() {
        if (sharedHazelcast == null) {
            if (!noSharedClusterWarningLogged) {
                logger.warn("No shared Hazelcast instance — outbox delivery skipped");
                noSharedClusterWarningLogged = true;
            }
            return;
        }
    
        List<OutboxEntry> claimed = outboxStore.claimPending(
                properties.getMaxBatchSize(), memberUuid);
    
        if (claimed.isEmpty()) {
            return;
        }
    
        for (OutboxEntry entry : claimed) {
            try {
                ITopic<GenericRecord> topic = sharedHazelcast.getTopic(entry.getEventType());
                topic.publish(entry.getEventRecord());
                outboxStore.markDelivered(entry.getEventId());
            } catch (Exception e) {
                if (entry.getRetryCount() + 1 >= properties.getMaxRetries()) {
                    outboxStore.markFailed(entry.getEventId(),
                            "Max retries exceeded: " + e.getMessage());
                } else {
                    outboxStore.incrementRetryCount(entry.getEventId(), e.getMessage());
                }
            }
        }
    }
    

    Note claimPending rather than pollPending. The claiming mechanism uses an EntryProcessor to atomically transition entries from PENDING to CLAIMED, tagging them with the claiming member’s UUID. This prevents two publisher instances from delivering the same event — important once you’re running multiple replicas.

    When no shared cluster is configured (single-node dev mode), the publisher logs one warning and stops trying. Events pile up as PENDING in the outbox. They’ll drain as soon as a shared cluster appears.

    Retry escalation is per-entry:

    Attempt 1: fails → incrementRetryCount (retryCount=1)
    Attempt 2: fails → incrementRetryCount (retryCount=2)
    ...
    Attempt 5: fails → markFailed (retryCount=5 >= maxRetries=5)
    

    Once marked FAILED, the entry stops showing up in claim results. The failure reason is preserved for debugging.

    Scheduling

    OutboxAutoConfiguration hooks the publisher into Spring’s task scheduler:

    @EnableScheduling
    public class OutboxAutoConfiguration implements SchedulingConfigurer {
    
        @Override
        public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
            taskRegistrar.addFixedDelayTask(() -> {
                outboxPublisher.waitForWork();       // blocks until signaled or timeout
                outboxPublisher.publishPendingEntries();
            }, 1);  // 1ms loop delay — actual timing controlled by semaphore
        }
    }
    

    The 1ms fixed delay means the loop restarts almost immediately after each cycle, but waitForWork() controls the actual pacing. The thread blocks on the semaphore until either a permit is released or the poll interval elapses. Near-instant delivery under normal load, guaranteed pickup if a signal is missed.


    Integration with EventSourcingController

    The controller’s republishToSharedCluster now checks for an outbox store first:

    private void republishToSharedCluster(PendingCompletion<K> pending) {
        if (sharedHazelcast == null || pending.eventRecord == null || pending.eventType == null) {
            return;
        }
        if (outboxStore != null) {
            OutboxEntry entry = new OutboxEntry(
                    pending.completionInfo.getEventId(),
                    pending.eventType,
                    pending.eventRecord
            );
            outboxStore.write(entry);
            if (outboxPublisher != null) {
                outboxPublisher.notifyNewEntry();
            }
        } else {
            // Legacy direct publish (when outbox is disabled)
            try {
                ITopic<GenericRecord> topic = sharedHazelcast.getTopic(pending.eventType);
                topic.publish(pending.eventRecord);
            } catch (Exception e) {
                logger.warn("Failed to republish event {}: {}", pending.eventType, e.getMessage());
            }
        }
    }
    

    Fully backward compatible. When outboxStore is injected, events go through the durable path. When it’s null, you get the old fire-and-forget behavior. The OutboxStore is wired through each service’s config as an optional dependency:

    @Bean
    public EventSourcingController<Order, String, DomainEvent<Order, String>> orderController(
            HazelcastInstance hazelcastInstance,
            @Qualifier("hazelcastClient") HazelcastInstance hazelcastClient,
            @Autowired(required = false) OutboxStore outboxStore,
            ...) {
        return EventSourcingController.builder()
                .hazelcast(hazelcastInstance)
                .sharedHazelcast(hazelcastClient)
                .outboxStore(outboxStore)
                .build();
    }
    

    Delivery Guarantees

    The outbox provides at-least-once delivery. If the publisher crashes after publishing to the ITopic but before calling markDelivered(), the next cycle picks up the same entry and delivers it again. Events are never lost as long as the embedded Hazelcast instance’s IMap data is intact.

    At-least-once means consumers may see duplicates. That’s where the Idempotency Guard from Part 9 comes in — it deduplicates on the consumer side, complementing the outbox’s guaranteed delivery.

    As for ordering: events for the same aggregate are written to the outbox in sequence order (the Jet pipeline processes them sequentially), and claimPending sorts by createdAt. But if two events are pending simultaneously and the first one fails while the second succeeds, they’ll arrive out of order. For our saga use case that’s acceptable — each step is identified by sagaId and eventType, and the saga state machine handles duplicates and out-of-order delivery.


    Configuration

    framework.outbox.*

    Property Default Description
    enabled true Master toggle for the outbox pattern
    poll-interval 1000 (ms) Fallback interval if signal is missed
    max-batch-size 50 Maximum entries per poll cycle
    max-retries 5 Delivery attempts before permanent failure
    entry-ttl 24h How long DELIVERED entries survive in the map

    Metrics

    Metric Type Description
    outbox.entries.written Counter Events written to the outbox
    outbox.entries.delivered Counter Events delivered to shared cluster
    outbox.entries.failed Counter Events permanently failed
    outbox.publish.duration Timer Time per publish cycle

    To disable the outbox and use direct publishing:

    framework:
      outbox:
        enabled: false
    

    What’s Next

    The outbox guarantees events reach the shared cluster. But what happens when they get there and the consumer can’t process them? The consumer might crash, the business logic might throw, the circuit breaker might be open.

    In Part 9, we add two patterns that work together: a Dead Letter Queue that captures events that fail consumer-side processing, and an Idempotency Guard that prevents duplicate processing — the natural flip side of at-least-once delivery.


    Next up: Dead Letter Queues and Idempotency

    Previous: Circuit Breakers and Retry: Resilient Hazelcast Sagas

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.