sz

Sejan

Blog

Building Resilient Event-Driven Systems with Redis Streams in Laravel: Consumer Groups, ACK Lifecycles, and Auto-Claiming

Home › Laravel 12 › Building Resilient Event-Driven Systems with Redis Streams in Laravel: Consumer Groups, ACK Lifecycles, and Auto-Claiming
Building Resilient Event-Driven Systems with Redis Streams in Laravel: Consumer Groups, ACK Lifecycles, and Auto-Claiming

When designing distributed microservices and asynchronous event pipelines, engineering teams often reach immediately for heavyweight message brokers like Apache Kafka or RabbitMQ. While these tools are exceptional for massive multi-terabyte data hubs, they introduce significant operational overhead: Zookeeper/KRaft clusters, complex partition management, and high baseline memory consumption.

For high-throughput web applications processing thousands of events per second, Redis Streams offers a remarkably fast, lightweight, and mathematically robust event-streaming engine built directly into your existing in-memory database.

Unlike standard Redis Pub/Sub (which provides ephemeral "fire-and-forget" broadcasting with zero historical persistence), Redis Streams provides an append-only log data structure with persistent message offsets, consumer groups, pending entries lists (PEL), and at-least-once delivery guarantees.

In this guide, we explore the internal mechanics of Redis Streams, build a custom streaming producer and consumer group worker in Laravel, and implement fault-tolerant message recovery using XAUTOCLAIM.


Pub/Sub vs. Lists vs. Redis Streams

Redis Data Structure Message Persistence Delivery Model Consumer Group & ACK Support
Pub/Sub (PUBLISH / SUBSCRIBE) None. If no subscriber is connected, the message is lost forever. Broadcast to all active listeners. ✗ No ACK mechanism.
Lists (LPUSH / BRPOP) Persistent in RAM until popped. Point-to-point (Single consumer consumes each item). ✗ No fan-out to multiple independent service groups.
Streams (XADD / XREADGROUP) Persistent append-only log with configurable trimming (MAXLEN). Fan-out to multiple Consumer Groups; load balanced within each group. ✓ Full XACK, Pending Entries List, and Dead-Letter recovery.

1. The Stream Anatomy: Consumer Groups & The Pending Entries List (PEL)

When multiple microservices consume the same event stream (e.g., when an order is placed, both the Payment Service and the Inventory Service must process the event independently):

  1. Stream Append (XADD): Producers append events with a monotonically increasing ID (<millisecondsTime>-<sequenceNumber>).
  2. Consumer Groups (XGROUP CREATE): Each service registers a named consumer group with its own independent stream cursor.
  3. Reading Pending Events (XREADGROUP): Workers inside a consumer group request new unassigned messages (using ID >). Redis assigns messages to the worker and records them in the Pending Entries List (PEL).
  4. Explicit Acknowledgement (XACK): Once a worker successfully finishes processing, it issues XACK. Redis removes the message from the PEL.
  5. Crash Recovery (XAUTOCLAIM): If a worker crashes mid-task, the message remains unacknowledged in the PEL. Another worker claims and processes abandoned messages after a timeout.

2. Producing Stream Events in Laravel

Let's create an event streaming producer service in Laravel:

<?php

namespace App\Services\Streams;

use Illuminate\Support\Facades\Redis;

class OrderStreamProducer
{
    protected const STREAM_KEY = 'stream:orders';

    /**
     * Publish an order event to Redis Streams with approximate trimming (~100k events).
     */
    public static function publish(string $eventType, array $data): string
    {
        $payload = [
            'event' => $eventType,
            'data' => json_encode($data),
            'timestamp' => (string) now()->getTimestampMs(),
        ];

        // XADD with MAXLEN ~ 100000 keeps memory bounded while retaining recent history
        return Redis::xadd(
            self::STREAM_KEY,
            'MAXLEN',
            '~',
            100000,
            '*',
            $payload
        );
    }
}

3. Building a Resilient Consumer Group Worker

Create an Artisan console command that runs continuously under systemd to consume stream events with automatic error recovery and dead-letter handling:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;

class ConsumeOrderStreamCommand extends Command
{
    protected $signature = 'stream:consume-orders {--group=inventory-service} {--consumer=}';
    protected $description = 'Consume order events from Redis Streams with ACK lifecycle and auto-claiming';

    protected string $stream = 'stream:orders';

    public function handle(): int
    {
        $group = $this->option('group');
        $consumer = $this->option('consumer') ?: 'worker-' . getmypid();

        // 1. Ensure consumer group exists (Start from beginning of stream '0')
        try {
            Redis::xgroup('CREATE', $this->stream, $group, '0', 'MKSTREAM');
            $this->info("Created consumer group: {$group}");
        } catch (Throwable $e) {
            // Group already exists, continue gracefully
        }

        $this->info("Worker {$consumer} listening on {$this->stream} (Group: {$group})...");

        while (true) {
            try {
                // 2. First, check for abandoned messages stuck in PEL for over 60 seconds (Crash Recovery)
                $claimed = Redis::xautoclaim($this->stream, $group, $consumer, 60000, '0-0', 'COUNT', 10);
                if (!empty($claimed[1])) {
                    foreach ($claimed[1] as $id => $fields) {
                        $this->processMessage($id, $fields, $group);
                    }
                }

                // 3. Read new unassigned messages with a 2-second blocking timeout
                $entries = Redis::xreadgroup($group, $consumer, [$this->stream => '>'], 10, 2000);

                if (!empty($entries[$this->stream])) {
                    foreach ($entries[$this->stream] as $id => $fields) {
                        $this->processMessage($id, $fields, $group);
                    }
                }
            } catch (Throwable $e) {
                Log::error("Stream consumer error: " . $e->getMessage());
                sleep(1);
            }
        }

        return self::SUCCESS;
    }

    protected function processMessage(string $id, array $fields, string $group): void
    {
        $event = $fields['event'] ?? 'unknown';
        $data = json_decode($fields['data'] ?? '{}', true);

        $this->line("→ Processing event [{$id}]: {$event}");

        try {
            // Business Logic: Reserve inventory or dispatch notifications
            // ...

            // 4. Acknowledge message successfully
            Redis::xack($this->stream, $group, [$id]);
        } catch (Throwable $e) {
            Log::error("Failed to process stream message {$id}: " . $e->getMessage());
            // Message remains in PEL for retry or DLQ routing
        }
    }
}

4. Stream Trimming & Memory Optimization

Because Redis is an in-memory database, unbounded streams will eventually exhaust RAM. Always use approximate trimming (MAXLEN ~) on production streams:

# Approximate trimming is O(1) computationally, avoiding re-indexing overhead:
XADD stream:orders MAXLEN ~ 50000 * event "order.placed" data "{\"id\":1024}"

Production Redis Streams Checklist

  • ✓ Always ACK after database commits: Issue XACK only after the consumer has successfully committed its database transaction.
  • ✓ Run XAUTOCLAIM in worker loops: Guarantees that if a worker process crashes due to OOM or network drops, other workers pick up abandoned messages.
  • ✓ Set approximate stream capping: Use MAXLEN ~ N on XADD to maintain predictable memory usage.
  • ✓ Monitor Consumer Group Lag: Run XINFO GROUPS stream:orders to monitor unconsumed message backlogs.

Conclusion

Redis Streams strikes the perfect balance between the lightweight simplicity of in-memory caching and the robust guarantees of enterprise event streaming. By leveraging consumer groups, explicit ACK lifecycles, and auto-claiming in Laravel, you can build resilient, distributed event-driven systems with sub-millisecond end-to-end latency.

Comments (0)

Leave a Reply ↓

No comments yet. Be the first to start the discussion!

Leave a Comment