Free lesson · GenAI Solutions Architecture
Implement event backbone with Redis Streams for AI workloads
You will build an AIEventBackbone using Redis Streams as the messaging infrastructure for decoupled AI workload processing. Implement EventPublisher with method publish(stream: str, event: BaseAIEvent) -> str that serializes the event using the schema registry's serialize_event(), validates against the registered schema before publishing, calls redis.xadd(stream, event_data, maxlen=100000) to publish to the appropriate stream (e.g., ai:events:inference, ai:events:evaluation, ai:events:guardrail, ai:events:ingestion), and returns the Redis message ID as the publish confirmation. Build EventConsumer base class with method consume(stream: str, group: str, consumer_id: str, handler: Callable) that creates a consumer group via redis.xgroup_create(stream, group, mkstream=True) with idempotent creation handling, reads messages with redis.xreadgroup(group, consumer_id, {stream: '>'}, count=10, block=5000), deserializes each message using the schema registry's deserialize_event(), calls the handler for each typed event, and acknowledges processed messages with redis.xack(stream, group, message_id). Implement three concrete consumers: InferenceEventConsumer that triggers downstream evaluation by publishing to ai:events:evaluation when inference completes with status success, EvaluationEventConsumer that updates quality dashboards by incrementing Prometheus counters and triggers Alertmanager alerts when quality scores drop below configured thresholds via alertmanager_api.post_alert(), and DataIngestionEventConsumer that triggers re-indexing by publishing cache invalidation events and refreshing materialized views in PostgreSQL. Build EventPipelineOrchestrator with method start_pipeline(config: PipelineConfig) that manages consumer lifecycle: starts consumers as asyncio tasks using asyncio.create_task(), handles graceful shutdown on SIGTERM by calling consumer.stop() and waiting for in-flight messages to complete, monitors consumer health via heartbeat checks every 10 seconds, and restarts crashed consumers with exponential backoff capped at 60 seconds. Store consumer state in Redis: consumer:{group}:{consumer_id}:last_processed tracking the last processed message ID, consumer:{group}:{consumer_id}:status tracking health (running, stopped, crashed), and consumer:{group}:{consumer_id}:heartbeat with 30-second TTL. Emit Prometheus metrics event_published_total{stream}, event_consumed_total{stream,group,status}, event_consumer_lag{stream,group} (computed from redis.xinfo_groups() pending count), event_processing_duration_seconds{stream,group}, event_backbone_throughput{stream,direction}, and event_consumer_restarts_total{group,consumer}. Build FastAPI endpoints GET /api/v1/events/streams listing all active streams with message counts, consumer group details, and lag, and GET /api/v1/events/consumers showing consumer group status with per-consumer health indicators.
Course: GenAI Architecture & Design Patterns · Chapter 13 · Event-Driven AI Processor
Free to read — no subscription required.
Introduction
When your AI inference, evaluation, and audit services call one another directly, a single slow consumer stalls the whole pipeline and one crash drops events that were never persisted anywhere replayable. Teams that bolt on retries, dead-letter handling, and replay capability after the fact end up reinventing a fragile, half-finished message bus — and lose the ability to debug a misbehaving model decision later, because the events that triggered it are gone. By the end of this lesson you will be able to stand up a Redis Streams event backbone with producer and consumer-group abstractions, publish GenAI domain events from your inference service, and consume them in parallel across independently scaling worker groups.
Key Terminology
- XADD: Redis Streams command that appends a new entry to a stream, assigning it a monotonically increasing timestamp-based ID and optionally trimming the stream to a maximum length.
- XREADGROUP: Redis Streams command used by consumer groups to read events; with the special
>ID it delivers only entries never seen by any consumer in the group, enabling distributed work-queue semantics. - Consumer group: A named cursor over a stream shared by multiple consumers, where Redis distributes entries so each event is delivered to exactly one consumer in the group and tracks per-group acknowledgment state independently.
- Pending Entry List (PEL): The per-consumer-group registry of entries that have been delivered via XREADGROUP but not yet acknowledged with XACK; used to recover unprocessed events after a consumer crashes.
Concepts
Key Design Considerations for Production
- Consumer naming strategy: Use a deterministic naming scheme like {service}-{pod-id} so that pending entries from a crashed consumer can be identified and reclaimed. Random consumer names make orphaned PEL entries difficult to trace during incident response.
- Stream trimming policy: The maxlen parameter with approximate=True prevents unbounded memory growth. For GenAI audit trails where retention matters, set maxlen conservatively high (1,000,000+) and implement a separate archival process that reads older entries to cold storage before they are trimmed.
Code Walkthrough
Building on the consumer-naming and stream-trimming decisions described above, this section turns them into two concrete abstractions: an EventPublisher that writes domain events with XADD, and an AIEventBackbone that creates consumer groups and consumes with XREADGROUP. Both use the redis.asyncio client so the inference service publishes without adding latency to the model response path.
The publisher serializes a BaseAIEvent dataclass to a flat field map and appends it with maxlen plus approximate=True, exactly the conservative-but-bounded trimming policy the concepts call for:
Code snippetpython
1import json 2import time 3from dataclasses import dataclass, field, asdict 4from redis.asyncio import Redis 5 6@dataclass 7class BaseAIEvent: 8 event_type: str 9 source_service: str 10 timestamp: float = field(default_factory=time.time) 11 correlation_id: str = "" 12 payload: dict = field(default_factory=dict) 13 14class EventPublisher: 15 def __init__(self, redis_client: Redis, max_stream_length: int = 1_000_000): 16 self._redis = redis_client 17 self._max_len = max_stream_length 18 19 async def publish(self, stream: str, event: BaseAIEvent) -> str: 20 fields = {k: str(v) for k, v in asdict(event).items() if k != "payload"} 21 fields["payload"] = json.dumps(event.payload) 22 return await self._redis.xadd( 23 stream, fields, maxlen=self._max_len, approximate=True 24 )
The backbone wraps the publisher and manages the read path. It creates a consumer group at the start of the stream, then reads with the special > ID so each entry reaches exactly one consumer in the group. The consumer name follows the deterministic {group}-{pod_id} scheme so a crashed pod's pending entries can be reclaimed later from its Pending Entry List. Each handled entry is acknowledged with XACK:
Code snippetpython
1class AIEventBackbone: 2 def __init__(self, redis_client: Redis): 3 self._redis = redis_client 4 self.publisher = EventPublisher(redis_client) 5 6 async def create_consumer_group(self, stream: str, group: str) -> None: 7 try: 8 await self._redis.xgroup_create(stream, group, id="0", mkstream=True) 9 except Exception: 10 pass # group already exists (BUSYGROUP) 11 12 async def consume(self, stream, group, pod_id, handler): 13 consumer = f"{group}-{pod_id}" # deterministic {service}-{pod-id} 14 while True: 15 batches = await self._redis.xreadgroup( 16 group, consumer, {stream: ">"}, count=10, block=2000 17 ) 18 for _name, messages in batches: 19 for entry_id, data in messages: 20 await handler(entry_id, data) 21 await self._redis.xack(stream, group, entry_id)
This separates the write path from the read path, letting producers and consumer groups scale independently. Verify by publishing one test event and confirming XREADGROUP delivers it to exactly one consumer per group while XACK clears it from the Pending Entry List.
Do's and Don'ts
Do's
- ✓Do serialize
BaseAIEvent.payloadwithjson.dumpsand all scalar fields withstr()separately insideEventPublisher.publish()— Redis Streams stores only byte strings; applyingstr()to thepayloaddict stores a Python repr ({'k': 'v'}) rather than valid JSON, so any consumer callingjson.loads(data["payload"])raises a parse error and the event is effectively unreadable downstream. - ✓Do pass
id="0"andmkstream=Truetoxgroup_createwhen standing up theAIEventBackbone—id="0"positions the consumer group at the head of the existing stream so events queued before the group was registered (e.g., during a rolling deploy) are not silently skipped;mkstream=Trueeliminates the race whereXREADGROUPreaches a stream the firstXADDhasn't yet created. - ✓Do name each consumer with the deterministic
{group}-{pod_id}pattern rather than a random UUID — TheAIEventBackbone.consume()loop ties the consumer name to an addressable pod so that, after a crash,XCLAIMcan target that exact Pending Entry List by name and reclaim the stuck entries; a random name on each restart orphans the PEL with no live consumer to claim it.
Don'ts
- ✗Don't pass a synchronous
redis.Redisclient toEventPublisherorAIEventBackbone— both classesawaittheir Redis calls (xadd,xreadgroup,xack,xgroup_create); a blocking client turns everyXADDin the inference publish path into a thread-stalling I/O call that adds latency directly to the model response, defeating the non-blocking contractredis.asyncioprovides. - ✗Don't re-raise the exception inside
create_consumer_group'sexceptblock — the method deliberately swallowsBUSYGROUPbecause a group already existing on pod restart is the normal steady-state, not a fault; surfacing the exception would crash the backbone on every redeployment against a live stream that already has the group registered. - ✗Don't call
XREADGROUPwith a literal message ID instead of the special">"sentinel in theconsume()loop —">"is the only value that delivers new, undelivered entries to the consumer; a numeric ID re-reads the consumer's own Pending Entry List instead, so new inference events published after startup are never received by any worker group.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Solutions Architecture subscription.
From · cancel anytime
More free lessons in GenAI Architecture & Design Patterns
- Ch 12Build A2A agent card registry with capability advertisement
- Ch 12Implement A2A task delegation with streaming artifact exchange
- Ch 12Validate A2A communication reliability with failure injection
- Ch 12Build A2A agent trust and authorization framework
- Ch 12Optimize A2A network topology for latency and reliability
- Ch 12Create A2A network operations dashboard with federation view
- Ch 13Implement event backbone with Redis Streams for AI workloadsYou are here