Event-Driven Architecture: Design, Deployment, and Hardening Best Practices¶
Objective: Master production-grade event-driven architecture across Kubernetes, messaging systems, databases, and distributed services. When you need to build resilient, scalable event-driven systems with proper observability and security—this guide provides complete patterns and implementations.
Introduction¶
Event-driven architecture (EDA) is the foundation of modern distributed systems. By decoupling producers and consumers through events, EDA enables scalable, resilient systems that can handle high throughput, recover from failures, and evolve independently. This guide provides a complete framework for designing, deploying, and operating event-driven systems.
What This Guide Covers: - Foundations of event-driven architecture - Critical messaging concepts (idempotency, backpressure, DLQs) - System integration patterns (Postgres, Redis, Kafka, NATS, MQTT) - Event contract design and schema evolution - End-to-end architecture examples - Operational best practices and observability - Security and hardening - Failure modes and recovery - Hands-on implementation examples
Prerequisites: - Understanding of distributed systems and messaging patterns - Familiarity with Kubernetes, databases, and containerization - Experience with at least one messaging system (Redis, Kafka, NATS, etc.)
Foundations of Event-Driven Architecture (EDA)¶
The Philosophy of EDA vs Request/Response¶
Request/Response (Synchronous): - Tight coupling between services - Blocking operations - Difficult to scale - Single point of failure - Hard to handle partial failures
Event-Driven (Asynchronous): - Loose coupling between services - Non-blocking operations - Natural horizontal scaling - Resilient to failures - Graceful degradation
graph LR
subgraph Sync["Request/Response"]
Client1["Client"]
API1["API"]
DB1["Database"]
Client1 -->|"Request"| API1
API1 -->|"Query"| DB1
DB1 -->|"Response"| API1
API1 -->|"Response"| Client1
end
subgraph Async["Event-Driven"]
Producer["Producer"]
Broker["Event Broker"]
Consumer1["Consumer 1"]
Consumer2["Consumer 2"]
Consumer3["Consumer 3"]
Producer -->|"Event"| Broker
Broker -->|"Event"| Consumer1
Broker -->|"Event"| Consumer2
Broker -->|"Event"| Consumer3
end
style Sync fill:#ffebee
style Async fill:#e8f5e9 Producers, Consumers, Brokers, Topics, Partitions¶
Producer: Service that publishes events to a topic/stream.
Consumer: Service that subscribes to events and processes them.
Broker: Message broker that routes events from producers to consumers.
Topic/Stream: Logical channel for events of a specific type.
Partition: Physical division of a topic for parallel processing.
# Producer example
class EventProducer:
def __init__(self, broker):
self.broker = broker
async def publish(self, topic: str, event: dict):
"""Publish event to topic"""
await self.broker.publish(topic, event)
# Consumer example
class EventConsumer:
def __init__(self, broker):
self.broker = broker
async def consume(self, topic: str, handler: callable):
"""Consume events from topic"""
async for event in self.broker.subscribe(topic):
await handler(event)
Push vs Pull Delivery Models¶
Push Model: - Broker pushes messages to consumers - Lower latency - Requires backpressure handling - Examples: MQTT, WebSockets, NATS
Pull Model: - Consumers pull messages from broker - Better flow control - Higher latency - Examples: Kafka, Redis Streams (XREAD)
# Push model (NATS)
async def push_consumer():
async def handler(msg):
await process_message(msg.data)
await nc.subscribe("events.>", cb=handler)
# Pull model (Kafka)
async def pull_consumer():
consumer = aiokafka.AIOKafkaConsumer("events")
async for msg in consumer:
await process_message(msg.value)
Command vs Event vs Fact Messages¶
Command: Instruction to perform an action (imperative).
{
"type": "command",
"action": "create_user",
"payload": {
"username": "alice",
"email": "alice@example.com"
}
}
Event: Notification that something happened (past tense).
{
"type": "event",
"event": "user_created",
"timestamp": "2024-01-15T10:00:00Z",
"payload": {
"user_id": "123",
"username": "alice"
}
}
Fact: Immutable statement of truth.
Event Sourcing Basics¶
Event Sourcing: Store all changes as a sequence of events.
# Event store
class EventStore:
def __init__(self, db):
self.db = db
async def append(self, stream_id: str, event: dict):
"""Append event to stream"""
await self.db.execute("""
INSERT INTO events (stream_id, event_type, payload, version)
VALUES (%s, %s, %s,
(SELECT COALESCE(MAX(version), 0) + 1
FROM events WHERE stream_id = %s))
""", (stream_id, event["type"], json.dumps(event), stream_id))
async def get_stream(self, stream_id: str) -> List[dict]:
"""Reconstruct state from events"""
events = await self.db.fetch("""
SELECT event_type, payload, version
FROM events
WHERE stream_id = %s
ORDER BY version
""", stream_id)
return [{"type": e["event_type"], "payload": json.loads(e["payload"])}
for e in events]
Durable vs Transient Messaging¶
Durable: Messages persisted to disk, survive broker restarts. - Use for: Critical business events, audit logs, financial transactions - Examples: Kafka, NATS JetStream, Redis Streams (with persistence)
Transient: Messages in memory only, lost on restart. - Use for: Real-time notifications, metrics, ephemeral state - Examples: NATS Core, MQTT (QoS 0), Redis Pub/Sub
Ordering, Consistency, Delivery Semantics¶
Ordering: - Per-partition ordering: Messages in same partition processed in order - Global ordering: All messages processed in order (single partition)
Consistency: - Eventual consistency: Systems converge over time - Strong consistency: Immediate consistency (harder to scale)
Delivery Semantics: - At-most-once: May lose messages, no duplicates - At-least-once: May duplicate, no loss - Exactly-once: No loss, no duplicates (hardest to achieve)
# At-least-once delivery with idempotency
class IdempotentConsumer:
def __init__(self, db):
self.db = db
async def process(self, event: dict):
"""Process event with idempotency"""
event_id = event["id"]
# Check if already processed
processed = await self.db.fetchval("""
SELECT 1 FROM processed_events WHERE event_id = %s
""", event_id)
if processed:
return # Already processed, skip
# Process event
await handle_event(event)
# Mark as processed
await self.db.execute("""
INSERT INTO processed_events (event_id, processed_at)
VALUES (%s, NOW())
""", event_id)
Critical Messaging Concepts¶
Idempotency¶
Dedup Keys:
# Natural key deduplication
class DedupProcessor:
def __init__(self, redis_client):
self.redis = redis_client
async def process(self, event: dict):
"""Process with natural key deduplication"""
# Use natural key (e.g., user_id + action)
dedup_key = f"event:{event['user_id']}:{event['action']}"
# Check if already processed
if await self.redis.get(dedup_key):
return # Duplicate, skip
# Process event
await handle_event(event)
# Mark as processed (TTL for cleanup)
await self.redis.setex(dedup_key, 86400, "1")
Hash-Based Deduplication:
import hashlib
class HashDedupProcessor:
def __init__(self, redis_client):
self.redis = redis_client
def event_hash(self, event: dict) -> str:
"""Generate hash for event"""
# Normalize event (remove timestamp, etc.)
normalized = {
"type": event["type"],
"payload": event["payload"]
}
event_str = json.dumps(normalized, sort_keys=True)
return hashlib.sha256(event_str.encode()).hexdigest()
async def process(self, event: dict):
"""Process with hash-based deduplication"""
event_hash = self.event_hash(event)
dedup_key = f"event_hash:{event_hash}"
if await self.redis.get(dedup_key):
return # Duplicate
await handle_event(event)
await self.redis.setex(dedup_key, 86400, "1")
Backpressure¶
Slow Consumer Handling:
# Backpressure with Redis Streams
class BackpressureConsumer:
def __init__(self, redis_client, max_queue_size: int = 1000):
self.redis = redis_client
self.max_queue_size = max_queue_size
self.processing = asyncio.Semaphore(10) # Max 10 concurrent
async def consume(self, stream: str, group: str, consumer: str):
"""Consume with backpressure"""
while True:
# Check queue size
queue_size = await self.redis.xlen(stream)
if queue_size > self.max_queue_size:
# Backpressure: slow down
await asyncio.sleep(1.0)
continue
# Read messages
messages = await self.redis.xreadgroup(
group, consumer, {stream: ">"},
count=10, block=1000
)
# Process with concurrency limit
for stream_name, msg_list in messages:
for msg_id, data in msg_list:
async with self.processing:
await self.process_message(msg_id, data)
await self.redis.xack(stream, group, msg_id)
Unbounded Queue Prevention:
class BoundedQueue:
def __init__(self, maxsize: int = 1000):
self.queue = asyncio.Queue(maxsize=maxsize)
self.dropped = 0
async def put(self, item):
"""Put item with backpressure"""
try:
await asyncio.wait_for(
self.queue.put(item),
timeout=0.1
)
except asyncio.TimeoutError:
# Queue full, drop message
self.dropped += 1
# Optionally: send to DLQ
await self.send_to_dlq(item)
Dead-Letter Queues (DLQs)¶
class DLQHandler:
def __init__(self, broker, dlq_topic: str):
self.broker = broker
self.dlq_topic = dlq_topic
async def send_to_dlq(self, event: dict, error: Exception, retry_count: int):
"""Send failed event to DLQ"""
dlq_event = {
"original_event": event,
"error": str(error),
"retry_count": retry_count,
"failed_at": datetime.now().isoformat(),
"dlq_reason": self.classify_error(error)
}
await self.broker.publish(self.dlq_topic, dlq_event)
def classify_error(self, error: Exception) -> str:
"""Classify error for DLQ routing"""
if isinstance(error, ValueError):
return "invalid_data"
elif isinstance(error, TimeoutError):
return "timeout"
elif isinstance(error, ConnectionError):
return "connection_error"
else:
return "unknown"
Replay and Time-Travel¶
class EventReplayer:
def __init__(self, event_store):
self.event_store = event_store
async def replay_from(self, stream_id: str, from_version: int):
"""Replay events from specific version"""
events = await self.event_store.get_stream(stream_id)
# Filter events from version
events_to_replay = [
e for e in events
if e["version"] >= from_version
]
# Replay in order
for event in events_to_replay:
await self.process_event(event)
async def replay_time_range(self, stream_id: str, start: datetime, end: datetime):
"""Replay events in time range"""
events = await self.event_store.get_events_in_range(
stream_id, start, end
)
for event in events:
await self.process_event(event)
Event Versioning¶
class VersionedEvent:
def __init__(self, event_type: str, version: int, payload: dict):
self.event_type = event_type
self.version = version
self.payload = payload
def to_dict(self) -> dict:
return {
"type": self.event_type,
"version": self.version,
"payload": self.payload,
"schema_version": f"{self.event_type}_v{self.version}"
}
# Event versioning with schema evolution
class EventVersionHandler:
def __init__(self):
self.handlers = {}
def register_handler(self, event_type: str, version: int, handler: callable):
"""Register handler for specific event version"""
key = f"{event_type}_v{version}"
self.handlers[key] = handler
async def handle(self, event: dict):
"""Handle event with version routing"""
event_type = event["type"]
version = event.get("version", 1)
key = f"{event_type}_v{version}"
handler = self.handlers.get(key)
if not handler:
# Try to find compatible handler
handler = self.find_compatible_handler(event_type, version)
if handler:
await handler(event)
else:
raise ValueError(f"No handler for {key}")
Topic Evolution¶
class TopicEvolution:
"""Handle topic evolution and migration"""
def migrate_topic(self, old_topic: str, new_topic: str):
"""Migrate from old topic to new topic"""
# Strategy 1: Dual-write during migration
async def dual_write(event: dict):
await broker.publish(old_topic, event)
await broker.publish(new_topic, event)
# Strategy 2: Consumer bridge
async def bridge_consumer():
async for event in broker.subscribe(old_topic):
# Transform if needed
transformed = self.transform_event(event)
await broker.publish(new_topic, transformed)
# Strategy 3: Gradual migration
# - Start dual-write
# - Migrate consumers one by one
# - Stop writing to old topic
# - Deprecate old topic
Retry Policies & Exponential Backoff¶
class RetryPolicy:
def __init__(
self,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 60.0,
multiplier: float = 2.0
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.multiplier = multiplier
async def execute_with_retry(self, func: callable, *args, **kwargs):
"""Execute function with exponential backoff retry"""
delay = self.initial_delay
for attempt in range(self.max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
if attempt == self.max_retries - 1:
raise # Last attempt, re-raise
# Exponential backoff with jitter
jitter = random.uniform(0, delay * 0.1)
await asyncio.sleep(delay + jitter)
delay = min(delay * self.multiplier, self.max_delay)
Recoverability vs Non-Recoverability¶
Recoverable Events: Can be reprocessed if failed. - Store in durable queue - Implement idempotency - Support replay
Non-Recoverable Events: Cannot be reprocessed (time-sensitive). - Transient messaging - Best-effort delivery - Accept loss
class RecoverableEventProcessor:
async def process(self, event: dict):
"""Process recoverable event"""
try:
await handle_event(event)
await self.mark_processed(event["id"])
except Exception as e:
await self.send_to_retry_queue(event, e)
class NonRecoverableEventProcessor:
async def process(self, event: dict):
"""Process non-recoverable event (best effort)"""
try:
await handle_event(event)
except Exception as e:
# Log and continue (don't retry)
logger.error(f"Failed to process non-recoverable event: {e}")
System Integration Patterns¶
Postgres LISTEN/NOTIFY Patterns¶
# Postgres LISTEN/NOTIFY producer
class PostgresEventProducer:
def __init__(self, db_pool):
self.db_pool = db_pool
async def notify_event(self, channel: str, payload: dict):
"""Send NOTIFY event"""
async with self.db_pool.acquire() as conn:
await conn.execute(
"SELECT pg_notify(%s, %s)",
channel,
json.dumps(payload)
)
# Postgres LISTEN/NOTIFY consumer
class PostgresEventConsumer:
def __init__(self, db_pool):
self.db_pool = db_pool
self.listeners = {}
async def listen(self, channel: str, handler: callable):
"""Listen for NOTIFY events"""
async with self.db_pool.acquire() as conn:
await conn.execute(f"LISTEN {channel}")
async for notify in conn.notifies():
payload = json.loads(notify.payload)
await handler(payload)
Trigger-Based Events:
-- Postgres trigger for event generation
CREATE OR REPLACE FUNCTION notify_user_event()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'user_events',
json_build_object(
'event_type', TG_OP,
'user_id', NEW.id,
'data', row_to_json(NEW)
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_event_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION notify_user_event();
Logical Replication Slots as Event Streams¶
# Logical replication consumer
class LogicalReplicationConsumer:
def __init__(self, connection_string: str, slot_name: str):
self.conn_string = connection_string
self.slot_name = slot_name
async def consume_changes(self, handler: callable):
"""Consume changes via logical replication"""
conn = await asyncpg.connect(self.conn_string)
# Create replication slot if not exists
await conn.execute(f"""
SELECT * FROM pg_create_logical_replication_slot(
'{self.slot_name}',
'pgoutput'
)
""")
# Start replication
async with conn.cursor(
f"START_REPLICATION SLOT {self.slot_name} LOGICAL 0/0"
) as cur:
async for change in cur:
event = self.parse_wal_message(change)
await handler(event)
FDW Triggers as Event Sources¶
-- FDW trigger for external data changes
CREATE OR REPLACE FUNCTION notify_fdw_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'fdw_changes',
json_build_object(
'table', TG_TABLE_NAME,
'operation', TG_OP,
'old', row_to_json(OLD),
'new', row_to_json(NEW)
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to FDW table
CREATE TRIGGER fdw_change_trigger
AFTER INSERT OR UPDATE OR DELETE ON s3_data
FOR EACH ROW EXECUTE FUNCTION notify_fdw_change();
PostGIS-Driven Event Updates¶
-- PostGIS spatial event trigger
CREATE OR REPLACE FUNCTION notify_spatial_change()
RETURNS TRIGGER AS $$
DECLARE
geom_changed BOOLEAN;
BEGIN
-- Check if geometry changed
IF TG_OP = 'UPDATE' THEN
geom_changed := NOT ST_Equals(OLD.geom, NEW.geom);
ELSE
geom_changed := TRUE;
END IF;
IF geom_changed THEN
PERFORM pg_notify(
'spatial_events',
json_build_object(
'event_type', TG_OP,
'feature_id', NEW.id,
'geometry', ST_AsGeoJSON(NEW.geom),
'bounds', ST_AsGeoJSON(ST_Envelope(NEW.geom))
)::text
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER spatial_event_trigger
AFTER INSERT OR UPDATE ON features
FOR EACH ROW EXECUTE FUNCTION notify_spatial_change();
Redis Streams¶
Stream Groups:
# Redis Streams consumer group
class RedisStreamConsumer:
def __init__(self, redis_client: redis.Redis, stream: str, group: str, consumer: str):
self.redis = redis_client
self.stream = stream
self.group = group
self.consumer = consumer
async def consume(self, handler: callable):
"""Consume from stream group"""
# Create consumer group if not exists
try:
await self.redis.xgroup_create(
self.stream, self.group, id="0", mkstream=True
)
except redis.ResponseError as e:
if "BUSYGROUP" not in str(e):
raise
while True:
# Read messages
messages = await self.redis.xreadgroup(
self.group, self.consumer,
{self.stream: ">"},
count=10, block=1000
)
for stream_name, msg_list in messages:
for msg_id, data in msg_list:
try:
await handler(msg_id, data)
# Acknowledge message
await self.redis.xack(self.stream, self.group, msg_id)
except Exception as e:
# Handle failure (retry, DLQ, etc.)
await self.handle_failure(msg_id, data, e)
Consumer Recovery:
class RedisStreamRecovery:
async def recover_pending_messages(self, stream: str, group: str, consumer: str):
"""Recover pending messages for consumer"""
# Get pending messages
pending = await self.redis.xpending_range(
stream, group, min="-", max="+", count=100,
consumername=consumer
)
for msg in pending:
msg_id = msg["message_id"]
idle_time = msg["time_since_delivered"]
# Claim if idle too long
if idle_time > 60000: # 60 seconds
claimed = await self.redis.xclaim(
stream, group, consumer, 60000, msg_id
)
if claimed:
await self.process_message(msg_id, claimed[0][1])
Multi-Worker Coordination:
class MultiWorkerCoordinator:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.worker_id = str(uuid.uuid4())
async def coordinate_workers(self, stream: str, group: str, num_workers: int):
"""Coordinate multiple workers"""
# Register worker
await self.redis.sadd(f"workers:{group}", self.worker_id)
await self.redis.expire(f"workers:{group}", 60)
# Heartbeat
asyncio.create_task(self.heartbeat(group))
# Consume
consumer = RedisStreamConsumer(
self.redis, stream, group, f"worker-{self.worker_id}"
)
await consumer.consume(self.handle_message)
async def heartbeat(self, group: str):
"""Send heartbeat to indicate worker is alive"""
while True:
await self.redis.setex(
f"worker:{self.worker_id}:heartbeat",
30,
datetime.now().isoformat()
)
await asyncio.sleep(10)
Kafka + TimescaleDB IoT Pipelines¶
# Kafka to TimescaleDB IoT pipeline
class KafkaTimescaleDBPipeline:
def __init__(self, kafka_consumer, timescale_client):
self.kafka = kafka_consumer
self.timescale = timescale_client
async def ingest_iot_data(self):
"""Ingest IoT data from Kafka to TimescaleDB"""
async for msg in self.kafka:
# Parse message
data = json.loads(msg.value)
# Downsample if needed
if self.should_downsample(data):
await self.downsample(data)
else:
# Insert raw data
await self.timescale.execute("""
INSERT INTO sensor_data (device_id, timestamp, value, location)
VALUES (%s, %s, %s, ST_MakePoint(%s, %s))
""", (
data["device_id"],
data["timestamp"],
data["value"],
data["lon"],
data["lat"]
))
def should_downsample(self, data: dict) -> bool:
"""Determine if data should be downsampled"""
# Downsample if older than 1 hour
data_time = datetime.fromisoformat(data["timestamp"])
return (datetime.now() - data_time).total_seconds() > 3600
async def downsample(self, data: dict):
"""Downsample data to hourly aggregates"""
await self.timescale.execute("""
INSERT INTO sensor_data_hourly (device_id, hour, avg_value, location)
VALUES (%s, date_trunc('hour', %s), %s, ST_MakePoint(%s, %s))
ON CONFLICT (device_id, hour)
DO UPDATE SET avg_value = (sensor_data_hourly.avg_value + %s) / 2
""", (
data["device_id"],
data["timestamp"],
data["value"],
data["lon"],
data["lat"],
data["value"]
))
Schema Registry Patterns:
# Kafka with schema registry
from confluent_kafka.schema_registry import SchemaRegistryClient
from confluent_kafka.schema_registry.avro import AvroSerializer
class SchemaRegistryProducer:
def __init__(self, schema_registry_url: str):
self.schema_registry = SchemaRegistryClient({
"url": schema_registry_url
})
async def publish_with_schema(self, topic: str, event: dict, schema: str):
"""Publish event with schema validation"""
# Get or register schema
schema_id = await self.get_or_register_schema(topic, schema)
# Serialize with schema
serializer = AvroSerializer(
self.schema_registry,
schema,
event
)
# Publish
await self.kafka_producer.produce(topic, serializer)
Partitioning Best Practices:
# Kafka partitioning strategy
class KafkaPartitioner:
def partition_key(self, event: dict) -> str:
"""Generate partition key for event"""
# Strategy 1: By user_id (ensures user events in order)
if "user_id" in event:
return str(event["user_id"])
# Strategy 2: By device_id (for IoT)
if "device_id" in event:
return str(event["device_id"])
# Strategy 3: By geographic region
if "location" in event:
return self.geohash_partition(event["location"])
# Default: round-robin
return None
def geohash_partition(self, location: dict) -> str:
"""Partition by geohash for geographic distribution"""
import geohash2
return geohash2.encode(location["lat"], location["lon"], precision=3)
NATS / JetStream¶
Real-Time Geospatial Micro-Updates:
# NATS for geospatial updates
import nats
from nats.aio.client import Client as NATS
class GeospatialNATSProducer:
def __init__(self, nc: NATS):
self.nc = nc
async def publish_location_update(self, device_id: str, location: dict):
"""Publish real-time location update"""
subject = f"location.updates.{device_id}"
payload = json.dumps({
"device_id": device_id,
"timestamp": datetime.now().isoformat(),
"lat": location["lat"],
"lon": location["lon"],
"accuracy": location.get("accuracy", 0)
})
await self.nc.publish(subject, payload.encode())
class GeospatialNATSConsumer:
async def subscribe_location_updates(self, device_pattern: str):
"""Subscribe to location updates"""
async def handler(msg):
data = json.loads(msg.data.decode())
await self.update_map(data)
await self.nc.subscribe(f"location.updates.{device_pattern}", cb=handler)
Command/Control Channels:
# NATS command/control
class CommandControl:
def __init__(self, nc: NATS):
self.nc = nc
async def send_command(self, device_id: str, command: dict):
"""Send command to device"""
subject = f"commands.{device_id}"
await self.nc.publish(subject, json.dumps(command).encode())
async def request_response(self, device_id: str, request: dict, timeout: float = 5.0):
"""Request-response pattern"""
subject = f"requests.{device_id}"
response = await self.nc.request(
subject,
json.dumps(request).encode(),
timeout=timeout
)
return json.loads(response.data.decode())
Lightweight Sensor Messaging:
# NATS for sensor data
class SensorNATS:
async def publish_sensor_data(self, sensor_id: str, data: dict):
"""Publish sensor data (lightweight)"""
subject = f"sensors.{sensor_id}.data"
# Use msgpack for efficiency
import msgpack
payload = msgpack.packb(data)
await self.nc.publish(subject, payload)
MQTT¶
Production-Safe MQTT Design:
# MQTT with QoS and LWT
import paho.mqtt.client as mqtt
class ProductionMQTTClient:
def __init__(self, broker: str, client_id: str):
self.client = mqtt.Client(client_id=client_id)
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
self.client.connect(broker, 1883, 60)
def on_connect(self, client, userdata, flags, rc):
"""Handle connection"""
if rc == 0:
# Subscribe with QoS 1 (at-least-once)
client.subscribe("sensors/+/data", qos=1)
else:
raise ConnectionError(f"MQTT connection failed: {rc}")
def setup_lwt(self, topic: str, payload: str):
"""Setup Last Will and Testament"""
self.client.will_set(topic, payload, qos=1, retain=False)
def publish_with_retain(self, topic: str, payload: dict, retain: bool = True):
"""Publish with retain flag for last known state"""
self.client.publish(
topic,
json.dumps(payload),
qos=1,
retain=retain
)
MQTT Heartbeats:
class MQTTHeartbeat:
def __init__(self, client: mqtt.Client, device_id: str):
self.client = client
self.device_id = device_id
self.heartbeat_topic = f"devices/{device_id}/heartbeat"
async def start_heartbeat(self, interval: int = 30):
"""Start heartbeat loop"""
while True:
self.client.publish(
self.heartbeat_topic,
json.dumps({
"device_id": self.device_id,
"timestamp": datetime.now().isoformat(),
"status": "alive"
}),
qos=1
)
await asyncio.sleep(interval)
Designing Event Contracts¶
Schema Evolution¶
# Schema evolution with versioning
class EventSchema:
def __init__(self, event_type: str, version: int, schema: dict):
self.event_type = event_type
self.version = version
self.schema = schema
def validate(self, event: dict) -> bool:
"""Validate event against schema"""
import jsonschema
try:
jsonschema.validate(event, self.schema)
return True
except jsonschema.ValidationError:
return False
def migrate(self, event: dict, target_version: int) -> dict:
"""Migrate event to target version"""
if self.version == target_version:
return event
# Apply migration rules
migrated = event.copy()
for v in range(self.version, target_version):
migrated = self.apply_migration(migrated, v, v + 1)
return migrated
OpenAPI/AsyncAPI Patterns¶
# AsyncAPI schema
asyncapi: '2.6.0'
info:
title: User Events API
version: '1.0.0'
channels:
user.created:
publish:
message:
payload:
type: object
properties:
user_id:
type: string
username:
type: string
email:
type: string
created_at:
type: string
format: date-time
Event Contracts as Code¶
# Event contract definition
from dataclasses import dataclass
from typing import Optional
from datetime import datetime
@dataclass
class UserCreatedEvent:
"""User created event contract"""
user_id: str
username: str
email: str
created_at: datetime
def to_dict(self) -> dict:
return {
"type": "user.created",
"version": 1,
"payload": {
"user_id": self.user_id,
"username": self.username,
"email": self.email,
"created_at": self.created_at.isoformat()
}
}
@classmethod
def from_dict(cls, data: dict) -> 'UserCreatedEvent':
return cls(
user_id=data["payload"]["user_id"],
username=data["payload"]["username"],
email=data["payload"]["email"],
created_at=datetime.fromisoformat(data["payload"]["created_at"])
)
Protocol Buffers¶
// events/user_events.proto
syntax = "proto3";
package events;
message UserCreated {
string user_id = 1;
string username = 2;
string email = 3;
int64 created_at = 4; // Unix timestamp
}
message UserUpdated {
string user_id = 1;
repeated string changed_fields = 2;
int64 updated_at = 3;
}
message EventEnvelope {
string event_type = 1;
int32 version = 2;
int64 timestamp = 3;
bytes payload = 4; // Serialized event
}
Python Usage:
# Python with Protocol Buffers
from events_pb2 import UserCreated, EventEnvelope
class ProtobufEventProducer:
def publish_user_created(self, user_id: str, username: str, email: str):
"""Publish user created event with protobuf"""
event = UserCreated(
user_id=user_id,
username=username,
email=email,
created_at=int(datetime.now().timestamp())
)
envelope = EventEnvelope(
event_type="user.created",
version=1,
timestamp=int(datetime.now().timestamp()),
payload=event.SerializeToString()
)
await self.broker.publish("user.events", envelope.SerializeToString())
Go Usage:
// Go with Protocol Buffers
package main
import (
"events"
"github.com/nats-io/nats.go"
)
func publishUserCreated(nc *nats.Conn, userID, username, email string) error {
event := &events.UserCreated{
UserId: userID,
Username: username,
Email: email,
CreatedAt: time.Now().Unix(),
}
data, err := event.Marshal()
if err != nil {
return err
}
return nc.Publish("user.events", data)
}
Rust Usage:
// Rust with Protocol Buffers
use prost::Message;
use events::UserCreated;
pub fn publish_user_created(
broker: &mut Broker,
user_id: String,
username: String,
email: String,
) -> Result<(), Error> {
let event = UserCreated {
user_id,
username,
email,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
};
let mut buf = Vec::new();
event.encode(&mut buf)?;
broker.publish("user.events", buf)
}
Avro¶
# Avro schema and serialization
from avro.schema import parse
import avro.io
import io
class AvroEventProducer:
def __init__(self, schema_json: str):
self.schema = parse(schema_json)
def serialize(self, event: dict) -> bytes:
"""Serialize event with Avro"""
writer = avro.io.DatumWriter(self.schema)
bytes_writer = io.BytesIO()
encoder = avro.io.BinaryEncoder(bytes_writer)
writer.write(event, encoder)
return bytes_writer.getvalue()
def deserialize(self, data: bytes) -> dict:
"""Deserialize Avro event"""
reader = avro.io.DatumReader(self.schema)
bytes_reader = io.BytesIO(data)
decoder = avro.io.BinaryDecoder(bytes_reader)
return reader.read(decoder)
JSON Schema¶
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["user.created", "user.updated", "user.deleted"]
},
"version": {
"type": "integer",
"minimum": 1
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"payload": {
"type": "object",
"properties": {
"user_id": {"type": "string"},
"username": {"type": "string"},
"email": {"type": "string", "format": "email"}
},
"required": ["user_id", "username", "email"]
}
},
"required": ["type", "version", "timestamp", "payload"]
}
RDF/OWL Metadata Envelopes¶
# RDF/OWL event metadata
from rdflib import Graph, Namespace, Literal
from rdflib.namespace import RDF, RDFS
class RDFEventEnvelope:
def __init__(self):
self.graph = Graph()
self.EV = Namespace("http://example.org/events/")
def create_envelope(self, event: dict) -> str:
"""Create RDF envelope for event"""
event_id = self.EV[f"event/{uuid.uuid4()}"]
self.graph.add((event_id, RDF.type, self.EV.Event))
self.graph.add((event_id, self.EV.eventType, Literal(event["type"])))
self.graph.add((event_id, self.EV.timestamp, Literal(event["timestamp"])))
self.graph.add((event_id, self.EV.payload, Literal(json.dumps(event["payload"]))))
return self.graph.serialize(format="json-ld")
Event Signature Hashing¶
# Event signature for reproducibility
import hashlib
import hmac
class EventSigner:
def __init__(self, secret_key: str):
self.secret_key = secret_key.encode()
def sign_event(self, event: dict) -> str:
"""Sign event for authenticity"""
# Create canonical representation
canonical = json.dumps(event, sort_keys=True, separators=(',', ':'))
# Generate HMAC signature
signature = hmac.new(
self.secret_key,
canonical.encode(),
hashlib.sha256
).hexdigest()
return signature
def verify_event(self, event: dict, signature: str) -> bool:
"""Verify event signature"""
expected = self.sign_event(event)
return hmac.compare_digest(expected, signature)
End-to-End Architecture Examples¶
Kafka + Postgres + Redis Streams¶
graph TB
subgraph Producers["Event Producers"]
API["FastAPI<br/>(User Actions)"]
ETL["ETL Pipeline<br/>(Data Updates)"]
GIS["GIS Service<br/>(Spatial Events)"]
end
subgraph Kafka["Kafka Cluster"]
Topic1["user.events"]
Topic2["data.events"]
Topic3["spatial.events"]
end
subgraph Postgres["Postgres + Logical Replication"]
DB["Postgres DB"]
Slot["Replication Slot"]
end
subgraph Redis["Redis Streams"]
Stream1["user_stream"]
Stream2["data_stream"]
end
subgraph Consumers["Event Consumers"]
Worker1["Worker Pool 1"]
Worker2["Worker Pool 2"]
Worker3["Worker Pool 3"]
end
API -->|"Publish"| Topic1
ETL -->|"Publish"| Topic2
GIS -->|"Publish"| Topic3
Topic1 -->|"Consume"| Worker1
Topic2 -->|"Consume"| Worker2
Topic3 -->|"Consume"| Worker3
DB -->|"Logical Replication"| Slot
Slot -->|"WAL Events"| Stream1
Slot -->|"WAL Events"| Stream2
Stream1 -->|"Consume"| Worker1
Stream2 -->|"Consume"| Worker2
style Producers fill:#e1f5ff
style Kafka fill:#fff4e1
style Postgres fill:#e8f5e9
style Redis fill:#f3e5f5
style Consumers fill:#ffebee RKE2 Cluster with Multi-Queue Fan-Out¶
graph TB
subgraph RKE2["RKE2 Cluster"]
subgraph Namespace1["Namespace: app"]
Producer["Event Producer<br/>(FastAPI)"]
end
subgraph Namespace2["Namespace: messaging"]
Kafka["Kafka<br/>(StatefulSet)"]
NATS["NATS JetStream<br/>(StatefulSet)"]
Redis["Redis Streams<br/>(StatefulSet)"]
end
subgraph Namespace3["Namespace: workers"]
Worker1["Worker Pool 1<br/>(Kafka Consumers)"]
Worker2["Worker Pool 2<br/>(NATS Consumers)"]
Worker3["Worker Pool 3<br/>(Redis Consumers)"]
end
end
Producer -->|"Fan-Out"| Kafka
Producer -->|"Fan-Out"| NATS
Producer -->|"Fan-Out"| Redis
Kafka -->|"Consume"| Worker1
NATS -->|"Consume"| Worker2
Redis -->|"Consume"| Worker3
style RKE2 fill:#e1f5ff
style Namespace1 fill:#fff4e1
style Namespace2 fill:#e8f5e9
style Namespace3 fill:#f3e5f5 Stateful ML Inference Workers¶
sequenceDiagram
participant Redis as Redis Streams
participant NATS as NATS JetStream
participant Prefect as Prefect
participant ML as ML Workers
participant Model as ONNX Model
Redis->>NATS: Forward events
NATS->>Prefect: Trigger flow
Prefect->>ML: Dispatch inference task
ML->>Model: Load model
Model->>ML: Inference result
ML->>NATS: Publish result
NATS->>Redis: Update state EDA Pipeline with NiceGUI UI Push-Updates¶
graph LR
subgraph Backend["Backend Services"]
API["FastAPI API"]
Worker["Event Worker"]
end
subgraph Broker["Message Broker"]
Redis["Redis Streams"]
end
subgraph Frontend["Frontend"]
NiceGUI["NiceGUI App"]
WS["WebSocket"]
end
API -->|"Publish Event"| Redis
Worker -->|"Process Event"| Redis
Redis -->|"Subscribe"| Worker
Worker -->|"Push Update"| WS
WS -->|"Real-Time Update"| NiceGUI
style Backend fill:#e1f5ff
style Broker fill:#fff4e1
style Frontend fill:#e8f5e9 Geospatial Streaming Architecture¶
graph TB
subgraph Sources["Data Sources"]
IoT["IoT Devices"]
Sensors["Sensors"]
Updates["Manual Updates"]
end
subgraph Ingestion["Ingestion Layer"]
MQTT["MQTT Broker"]
Kafka["Kafka"]
end
subgraph Processing["Processing Layer"]
GeoProc["Geospatial Processor"]
TileGen["Tile Generator"]
H3Proc["H3 Processor"]
end
subgraph Storage["Storage Layer"]
PostGIS["PostGIS"]
TileStore["Tile Store"]
Cache["Redis Cache"]
end
subgraph Delivery["Delivery Layer"]
API["REST API"]
WS["WebSocket"]
CDN["CDN"]
end
IoT --> MQTT
Sensors --> MQTT
Updates --> Kafka
MQTT --> GeoProc
Kafka --> GeoProc
GeoProc --> PostGIS
GeoProc --> TileGen
GeoProc --> H3Proc
TileGen --> TileStore
H3Proc --> Cache
PostGIS --> API
TileStore --> CDN
Cache --> WS
style Sources fill:#e1f5ff
style Ingestion fill:#fff4e1
style Processing fill:#e8f5e9
style Storage fill:#f3e5f5
style Delivery fill:#ffebee Operational Best Practices¶
Capacity Planning for Queues¶
# Queue capacity planning
class QueueCapacityPlanner:
def calculate_queue_size(
self,
message_rate: float, # messages per second
processing_time: float, # seconds per message
peak_multiplier: float = 3.0,
retention_hours: float = 24.0
) -> int:
"""Calculate required queue size"""
# Base queue size
base_size = message_rate * processing_time
# Peak capacity
peak_size = base_size * peak_multiplier
# Retention capacity
retention_size = message_rate * 3600 * retention_hours
# Total required
total_size = max(peak_size, retention_size)
return int(total_size * 1.2) # 20% buffer
Horizontal Scaling of Consumers¶
# Kubernetes HPA for consumers
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: event-consumer-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: event-consumer
minReplicas: 2
maxReplicas: 10
metrics:
- type: Pods
pods:
metric:
name: consumer_lag
target:
type: AverageValue
averageValue: "100" # Scale when lag > 100 messages
Hot Partition Avoidance¶
# Hot partition detection and mitigation
class HotPartitionDetector:
def detect_hot_partitions(self, topic: str, threshold: float = 0.8) -> List[str]:
"""Detect hot partitions"""
partition_metrics = self.get_partition_metrics(topic)
total_load = sum(p["message_rate"] for p in partition_metrics.values())
avg_load = total_load / len(partition_metrics)
hot_partitions = []
for partition, metrics in partition_metrics.items():
if metrics["message_rate"] > avg_load * (1 + threshold):
hot_partitions.append(partition)
return hot_partitions
def rebalance_partitions(self, hot_partitions: List[str]):
"""Rebalance to reduce hot partitions"""
# Strategy 1: Increase partition count
# Strategy 2: Redistribute keys
# Strategy 3: Add more consumers to hot partitions
pass
Multi-Node Leader Election¶
# Leader election with Redis
class LeaderElection:
def __init__(self, redis_client: redis.Redis, key: str, ttl: int = 30):
self.redis = redis_client
self.key = key
self.ttl = ttl
self.leader_id = str(uuid.uuid4())
async def try_acquire_leadership(self) -> bool:
"""Try to acquire leadership"""
acquired = await self.redis.set(
self.key,
self.leader_id,
nx=True, # Only set if not exists
ex=self.ttl
)
if acquired:
# Renew lease periodically
asyncio.create_task(self.renew_lease())
return True
return False
async def renew_lease(self):
"""Renew leadership lease"""
while True:
await asyncio.sleep(self.ttl // 2)
# Check if still leader
current_leader = await self.redis.get(self.key)
if current_leader == self.leader_id.encode():
await self.redis.expire(self.key, self.ttl)
else:
break # Lost leadership
Handling Node Churn in RKE2 Clusters¶
# Node churn handling
class NodeChurnHandler:
def __init__(self, k8s_client):
self.k8s = k8s_client
async def handle_node_removal(self, node_name: str):
"""Handle node removal gracefully"""
# 1. Drain node
await self.k8s.drain_node(node_name)
# 2. Rebalance partitions
await self.rebalance_partitions(node_name)
# 3. Migrate stateful workloads
await self.migrate_stateful_workloads(node_name)
# 4. Update consumer groups
await self.update_consumer_groups(node_name)
async def handle_node_addition(self, node_name: str):
"""Handle node addition"""
# 1. Label node
await self.k8s.label_node(node_name, "role=worker")
# 2. Rebalance workloads
await self.rebalance_workloads()
# 3. Update consumer groups
await self.update_consumer_groups()
Event TTL Governance¶
# Event TTL management
class EventTTLManager:
def __init__(self, broker):
self.broker = broker
async def set_event_ttl(self, topic: str, ttl_seconds: int):
"""Set TTL for events in topic"""
# Kafka: retention.ms
# Redis: EXPIRE on stream
# NATS: max_age in stream config
await self.broker.set_topic_config(topic, {
"retention_ms": ttl_seconds * 1000
})
async def cleanup_expired_events(self, topic: str):
"""Cleanup expired events"""
# Get events older than TTL
cutoff = datetime.now() - timedelta(seconds=self.get_ttl(topic))
# Delete expired events
await self.broker.delete_events_before(topic, cutoff)
Snapshotting State Stores¶
# State store snapshots
class StateStoreSnapshotter:
def __init__(self, state_store, snapshot_store):
self.state_store = state_store
self.snapshot_store = snapshot_store
async def create_snapshot(self, stream_id: str, version: int):
"""Create snapshot of state at version"""
# Get current state
state = await self.state_store.get_state(stream_id)
# Create snapshot
snapshot = {
"stream_id": stream_id,
"version": version,
"state": state,
"timestamp": datetime.now().isoformat()
}
# Store snapshot
await self.snapshot_store.save(snapshot)
async def restore_from_snapshot(self, stream_id: str, snapshot_version: int):
"""Restore state from snapshot"""
snapshot = await self.snapshot_store.load(stream_id, snapshot_version)
# Restore state
await self.state_store.set_state(stream_id, snapshot["state"])
# Replay events after snapshot
await self.replay_events_after(stream_id, snapshot_version)
Observability & Monitoring¶
Metrics to Track¶
Redis Streams Metrics:
# Redis Streams metrics
redis_stream_metrics = {
"stream_length": "XLEN stream_name",
"consumer_lag": "XPENDING stream_name group_name",
"consumer_group_members": "XINFO GROUPS stream_name",
"memory_usage": "MEMORY USAGE stream_name"
}
Kafka Metrics:
# Kafka metrics
kafka_metrics = {
"consumer_lag": "kafka_consumer_lag_sum",
"producer_throughput": "kafka_producer_throughput",
"broker_partition_count": "kafka_broker_partition_count",
"topic_size": "kafka_log_size_bytes"
}
NATS Metrics:
# NATS metrics
nats_metrics = {
"messages_sent": "nats_core_messages_sent_total",
"messages_received": "nats_core_messages_received_total",
"jetstream_messages": "nats_jetstream_messages_total",
"jetstream_consumers": "nats_jetstream_consumers"
}
Postgres Metrics:
# Postgres logical replication metrics
postgres_metrics = {
"replication_lag": "pg_replication_lag_bytes",
"replication_slot_size": "pg_replication_slot_size_bytes",
"notify_events": "pg_notify_events_total"
}
Grafana Dashboards¶
{
"dashboard": {
"title": "Event-Driven Architecture Monitoring",
"panels": [
{
"title": "Consumer Lag",
"targets": [
{
"expr": "kafka_consumer_lag_sum",
"legendFormat": "{{topic}}/{{partition}}"
}
],
"alert": {
"conditions": [
{
"evaluator": {"params": [1000], "type": "gt"},
"operator": {"type": "and"},
"query": {"params": ["A", "5m", "now"]},
"reducer": {"type": "last"},
"type": "query"
}
]
}
},
{
"title": "Message Throughput",
"targets": [
{
"expr": "rate(kafka_producer_throughput[5m])",
"legendFormat": "{{topic}}"
}
]
},
{
"title": "Backpressure",
"targets": [
{
"expr": "queue_size / queue_capacity",
"legendFormat": "{{queue}}"
}
]
}
]
}
}
Prometheus Alert Rules¶
# monitoring/eda-alerts.yaml
groups:
- name: eda_alerts
interval: 5m
rules:
- alert: HighConsumerLag
expr: kafka_consumer_lag_sum > 1000
for: 5m
labels:
severity: warning
annotations:
summary: "High consumer lag in {{ $labels.topic }}/{{ $labels.partition }}"
- alert: BackpressureGrowing
expr: rate(queue_size[5m]) > 0.1
for: 10m
labels:
severity: critical
annotations:
summary: "Backpressure growing in {{ $labels.queue }}"
- alert: BrokerPartitionImbalance
expr: |
(
max(kafka_broker_partition_count) -
min(kafka_broker_partition_count)
) > 10
for: 15m
labels:
severity: warning
annotations:
summary: "Broker partition imbalance detected"
- alert: DroppedMessages
expr: rate(dropped_messages_total[5m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Messages being dropped in {{ $labels.topic }}"
Loki Structured Event Logs¶
# Structured event logging
import structlog
logger = structlog.get_logger()
class EventLogger:
def log_event(self, event: dict, level: str = "info"):
"""Log event with structured logging"""
logger.log(
level,
event_type=event["type"],
event_id=event.get("id"),
timestamp=event.get("timestamp"),
payload=event.get("payload"),
component="event_processor"
)
Loki Queries:
# Find all error events
{component="event_processor"} |= "error"
# Events by type
{component="event_processor"} | json | event_type="user.created"
# Consumer lag events
{component="consumer"} | json | lag > 1000
Security & Hardening¶
mTLS¶
# Kafka mTLS configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: kafka-mtls-config
data:
server.properties: |
listeners=SSL://0.0.0.0:9093
ssl.keystore.location=/etc/kafka/secrets/kafka.keystore
ssl.keystore.password=${KAFKA_KEYSTORE_PASSWORD}
ssl.truststore.location=/etc/kafka/secrets/kafka.truststore
ssl.truststore.password=${KAFKA_TRUSTSTORE_PASSWORD}
ssl.client.auth=required
Topic-Level ACLs¶
# Kafka ACL management
class KafkaACLManager:
def grant_topic_access(self, principal: str, topic: str, operation: str):
"""Grant topic access"""
subprocess.run([
"kafka-acls",
"--authorizer-properties", "zookeeper.connect=localhost:2181",
"--add",
"--allow-principal", principal,
"--operation", operation,
"--topic", topic
])
Multi-Tenant Isolation¶
# Multi-tenant event isolation
class MultiTenantEventRouter:
def __init__(self, broker):
self.broker = broker
def get_tenant_topic(self, tenant_id: str, base_topic: str) -> str:
"""Get tenant-specific topic"""
return f"{base_topic}.tenant.{tenant_id}"
async def publish_tenant_event(self, tenant_id: str, event: dict):
"""Publish event to tenant-specific topic"""
topic = self.get_tenant_topic(tenant_id, event["type"])
await self.broker.publish(topic, event)
Preventing Event Injection¶
# Event injection prevention
class EventSanitizer:
def sanitize_event(self, event: dict) -> dict:
"""Sanitize event to prevent injection"""
sanitized = {}
for key, value in event.items():
# Sanitize keys
sanitized_key = self.sanitize_key(key)
# Sanitize values
if isinstance(value, str):
sanitized_value = self.sanitize_string(value)
elif isinstance(value, dict):
sanitized_value = self.sanitize_event(value)
else:
sanitized_value = value
sanitized[sanitized_key] = sanitized_value
return sanitized
def sanitize_key(self, key: str) -> str:
"""Sanitize event key"""
# Remove dangerous characters
return re.sub(r'[^a-zA-Z0-9_]', '', key)
def sanitize_string(self, value: str) -> str:
"""Sanitize string value"""
# Remove script tags, etc.
return re.sub(r'<script[^>]*>.*?</script>', '', value, flags=re.DOTALL)
Signing & Verifying Messages¶
# Message signing
class MessageSigner:
def __init__(self, private_key: bytes):
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, padding
self.private_key = private_key
def sign_message(self, message: bytes) -> bytes:
"""Sign message with private key"""
return self.private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
def verify_message(self, message: bytes, signature: bytes, public_key) -> bool:
"""Verify message signature"""
try:
public_key.verify(
signature,
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
return True
except Exception:
return False
Audit Logging for EDA¶
# EDA audit logging
class EDAAuditLogger:
def log_event_publication(self, event: dict, producer: str):
"""Log event publication"""
self.audit_log.info(
"event_published",
event_id=event.get("id"),
event_type=event.get("type"),
producer=producer,
timestamp=datetime.now().isoformat()
)
def log_event_consumption(self, event: dict, consumer: str):
"""Log event consumption"""
self.audit_log.info(
"event_consumed",
event_id=event.get("id"),
event_type=event.get("type"),
consumer=consumer,
timestamp=datetime.now().isoformat()
)
Redaction Chains for Sensitive Geospatial Data¶
# Geospatial data redaction
class GeospatialRedactor:
def redact_sensitive_data(self, event: dict) -> dict:
"""Redact sensitive geospatial data"""
redacted = event.copy()
if "location" in redacted.get("payload", {}):
location = redacted["payload"]["location"]
# Reduce precision for privacy
redacted["payload"]["location"] = {
"lat": round(location["lat"], 2), # Reduce to ~1km precision
"lon": round(location["lon"], 2)
}
return redacted
Failure Modes & Real-World Disaster Scenarios¶
Broker Failure¶
Scenario: Kafka broker crashes, losing in-flight messages.
Recovery:
# Broker failure recovery
class BrokerFailureRecovery:
async def recover_from_broker_failure(self):
"""Recover from broker failure"""
# 1. Detect failure
if not await self.broker.is_healthy():
# 2. Failover to backup broker
await self.failover_to_backup()
# 3. Replay uncommitted messages
await self.replay_uncommitted_messages()
# 4. Verify consumer positions
await self.verify_consumer_positions()
Duplicate Event Storms¶
Scenario: Network partition causes duplicate events.
Mitigation:
# Duplicate event storm mitigation
class DuplicateStormMitigator:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.dedup_window = 3600 # 1 hour
async def handle_event_storm(self, events: List[dict]):
"""Handle duplicate event storm"""
# Group by dedup key
grouped = {}
for event in events:
key = self.get_dedup_key(event)
if key not in grouped:
grouped[key] = []
grouped[key].append(event)
# Process only first occurrence
for key, event_list in grouped.items():
# Check if already processed
if not await self.redis.get(f"processed:{key}"):
# Process first event
await self.process_event(event_list[0])
# Mark as processed
await self.redis.setex(
f"processed:{key}",
self.dedup_window,
"1"
)
Consumer Blackout¶
Scenario: All consumers fail, messages accumulate.
Recovery:
# Consumer blackout recovery
class ConsumerBlackoutRecovery:
async def recover_from_blackout(self):
"""Recover from consumer blackout"""
# 1. Restart consumers
await self.restart_consumers()
# 2. Check queue depth
queue_depth = await self.get_queue_depth()
if queue_depth > self.threshold:
# 3. Scale up consumers
await self.scale_up_consumers(queue_depth)
# 4. Process in batches
await self.process_in_batches()
Schema Mismatch¶
Scenario: Producer sends new schema, consumers expect old.
Mitigation:
# Schema mismatch handling
class SchemaMismatchHandler:
def handle_schema_mismatch(self, event: dict, expected_schema: dict):
"""Handle schema mismatch"""
try:
# Try to validate
jsonschema.validate(event, expected_schema)
except jsonschema.ValidationError as e:
# Schema mismatch detected
# Option 1: Reject and send to DLQ
await self.send_to_dlq(event, str(e))
# Option 2: Transform to compatible schema
transformed = self.transform_event(event, expected_schema)
await self.process_event(transformed)
Stale Replicas¶
Scenario: Consumer reads from stale replica.
Mitigation:
# Stale replica detection
class StaleReplicaDetector:
def detect_stale_replica(self, replica_lag: float, threshold: float = 10.0):
"""Detect stale replica"""
if replica_lag > threshold:
# Switch to primary
await self.switch_to_primary()
# Alert
await self.alert_stale_replica(replica_lag)
Network Partitions in RKE2 Cluster¶
Scenario: Network partition splits cluster.
Recovery:
# Network partition recovery
class NetworkPartitionRecovery:
async def recover_from_partition(self):
"""Recover from network partition"""
# 1. Detect partition
if await self.detect_partition():
# 2. Isolate affected nodes
await self.isolate_partitioned_nodes()
# 3. Continue with majority partition
await self.continue_with_majority()
# 4. Rejoin when partition heals
await self.rejoin_partitioned_nodes()
Air-Gapped Cluster Drift¶
Scenario: Air-gapped cluster drifts from upstream.
Mitigation:
# Air-gapped drift mitigation
class AirGapDriftMitigator:
async def sync_from_upstream(self, upstream_config: dict):
"""Sync configuration from upstream"""
# 1. Compare configurations
drift = await self.compare_configs(upstream_config)
if drift:
# 2. Apply upstream configuration
await self.apply_upstream_config(upstream_config)
# 3. Verify sync
await self.verify_sync()
Redis Stream Corruption¶
Scenario: Redis stream data corruption.
Recovery:
# Redis stream corruption recovery
class RedisStreamRecovery:
async def recover_from_corruption(self, stream: str):
"""Recover from stream corruption"""
# 1. Backup corrupted stream
await self.backup_stream(stream)
# 2. Recreate stream from backup
await self.recreate_stream(stream)
# 3. Replay events from event store
await self.replay_events_from_store(stream)
Full Hands-On Examples¶
Python asyncio + Redis Streams¶
# Complete Redis Streams consumer with asyncio
import asyncio
import redis.asyncio as redis
import json
from datetime import datetime
class AsyncRedisStreamConsumer:
def __init__(self, redis_url: str, stream: str, group: str, consumer: str):
self.redis = None
self.redis_url = redis_url
self.stream = stream
self.group = group
self.consumer = consumer
self.running = False
async def connect(self):
"""Connect to Redis"""
self.redis = await redis.from_url(self.redis_url)
# Create consumer group
try:
await self.redis.xgroup_create(
self.stream, self.group, id="0", mkstream=True
)
except redis.ResponseError as e:
if "BUSYGROUP" not in str(e):
raise
async def consume(self, handler: callable):
"""Consume events from stream"""
self.running = True
while self.running:
try:
# Read messages
messages = await self.redis.xreadgroup(
self.group, self.consumer,
{self.stream: ">"},
count=10, block=1000
)
for stream_name, msg_list in messages:
for msg_id, data in msg_list:
try:
# Parse event
event = json.loads(data[b"data"].decode())
# Process event
await handler(event)
# Acknowledge
await self.redis.xack(
self.stream, self.group, msg_id
)
except Exception as e:
# Handle error
await self.handle_error(msg_id, data, e)
except Exception as e:
logger.error(f"Consumer error: {e}")
await asyncio.sleep(1)
async def handle_error(self, msg_id: str, data: dict, error: Exception):
"""Handle processing error"""
# Send to DLQ after max retries
retry_count = await self.get_retry_count(msg_id)
if retry_count >= 3:
await self.send_to_dlq(msg_id, data, error)
else:
# Retry with backoff
await asyncio.sleep(2 ** retry_count)
await self.redis.xadd(
f"{self.stream}:retry",
{"data": json.dumps(data), "retry_count": retry_count + 1}
)
# Usage
async def main():
consumer = AsyncRedisStreamConsumer(
"redis://localhost:6379",
"events",
"workers",
"worker-1"
)
await consumer.connect()
async def handle_event(event: dict):
print(f"Processing event: {event['type']}")
# Process event
await process_event(event)
await consumer.consume(handle_event)
if __name__ == "__main__":
asyncio.run(main())
Go + NATS JetStream¶
// Go NATS JetStream consumer
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
type EventConsumer struct {
js jetstream.JetStream
ctx context.Context
}
func NewEventConsumer(nc *nats.Conn) (*EventConsumer, error) {
js, err := jetstream.New(nc)
if err != nil {
return nil, err
}
return &EventConsumer{
js: js,
ctx: context.Background(),
}, nil
}
func (ec *EventConsumer) Consume(streamName, consumerName string, handler func([]byte) error) error {
// Get stream
stream, err := ec.js.Stream(ec.ctx, streamName)
if err != nil {
return err
}
// Get or create consumer
consumer, err := stream.CreateOrUpdateConsumer(ec.ctx, jetstream.ConsumerConfig{
Name: consumerName,
Durable: consumerName,
AckPolicy: jetstream.AckExplicitPolicy,
MaxDeliver: 3,
AckWait: 30 * time.Second,
})
if err != nil {
return err
}
// Consume messages
messages, err := consumer.Messages()
if err != nil {
return err
}
for msg := range messages {
if err := handler(msg.Data()); err != nil {
log.Printf("Error processing message: %v", err)
msg.Nak()
continue
}
msg.Ack()
}
return nil
}
func main() {
nc, err := nats.Connect("nats://localhost:4222")
if err != nil {
log.Fatal(err)
}
defer nc.Close()
consumer, err := NewEventConsumer(nc)
if err != nil {
log.Fatal(err)
}
handler := func(data []byte) error {
var event map[string]interface{}
if err := json.Unmarshal(data, &event); err != nil {
return err
}
fmt.Printf("Processing event: %v\n", event)
return nil
}
if err := consumer.Consume("EVENTS", "worker-1", handler); err != nil {
log.Fatal(err)
}
}
Rust + Kafka Event Consumer¶
// Rust Kafka consumer
use rdkafka::config::ClientConfig;
use rdkafka::consumer::{Consumer, StreamConsumer};
use rdkafka::message::BorrowedMessage;
use rdkafka::Message;
use std::time::Duration;
async fn consume_events() -> Result<(), Box<dyn std::error::Error>> {
let consumer: StreamConsumer = ClientConfig::new()
.set("bootstrap.servers", "localhost:9092")
.set("group.id", "rust-consumer")
.set("enable.partition.eof", "false")
.set("session.timeout.ms", "6000")
.set("enable.auto.commit", "false")
.create()?;
consumer.subscribe(&["events"])?;
let mut message_stream = consumer.stream();
while let Some(message) = message_stream.next().await {
match message {
Ok(m) => {
process_message(&m)?;
consumer.commit_message(&m, rdkafka::consumer::CommitMode::Async)?;
}
Err(e) => {
eprintln!("Error receiving message: {:?}", e);
}
}
}
Ok(())
}
fn process_message(message: &BorrowedMessage) -> Result<(), Box<dyn std::error::Error>> {
let payload = message.payload().ok_or("No payload")?;
let event: serde_json::Value = serde_json::from_slice(payload)?;
println!("Processing event: {:?}", event);
Ok(())
}
Prefect Flows Orchestrating Multi-Stage Event Pipelines¶
# Prefect event pipeline
from prefect import flow, task
from prefect.blocks.core import Block
@task
async def consume_events(stream: str) -> List[dict]:
"""Consume events from stream"""
# Implementation
pass
@task
async def transform_event(event: dict) -> dict:
"""Transform event"""
# Implementation
pass
@task
async def enrich_event(event: dict) -> dict:
"""Enrich event with additional data"""
# Implementation
pass
@task
async def publish_event(event: dict, topic: str):
"""Publish transformed event"""
# Implementation
pass
@flow
async def event_pipeline_flow(stream: str, output_topic: str):
"""Multi-stage event pipeline"""
# Stage 1: Consume
events = await consume_events(stream)
# Stage 2: Transform and enrich in parallel
transformed = await transform_event.map(events)
enriched = await enrich_event.map(transformed)
# Stage 3: Publish
await publish_event.map(enriched, output_topic)
Postgres Triggers → Event Bus → Workers¶
-- Postgres trigger for event generation
CREATE OR REPLACE FUNCTION publish_to_event_bus()
RETURNS TRIGGER AS $$
DECLARE
event_payload JSONB;
BEGIN
event_payload := jsonb_build_object(
'event_type', TG_OP,
'table', TG_TABLE_NAME,
'old', row_to_json(OLD),
'new', row_to_json(NEW),
'timestamp', NOW()
);
-- Publish to Redis Streams via pg_notify
PERFORM pg_notify('event_bus', event_payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_events_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION publish_to_event_bus();
# Worker consuming from event bus
class PostgresEventWorker:
async def consume_from_bus(self):
"""Consume events from Postgres event bus"""
async with self.db_pool.acquire() as conn:
await conn.execute("LISTEN event_bus")
async for notify in conn.notifies():
event = json.loads(notify.payload)
await self.process_event(event)
NiceGUI UI Live Updates Using WebSocket Events¶
# NiceGUI with WebSocket events
from nicegui import ui, app
import asyncio
import redis.asyncio as redis
class NiceGUIEventUpdater:
def __init__(self, redis_url: str):
self.redis_url = redis_url
self.redis = None
async def connect(self):
"""Connect to Redis"""
self.redis = await redis.from_url(self.redis_url)
@ui.page("/dashboard")
async def dashboard(self):
"""Dashboard with live updates"""
# Create UI elements
status_label = ui.label("Status: Connecting...")
data_table = ui.table(columns=["Time", "Event", "Data"], rows=[])
# WebSocket for live updates
@app.on_connect
async def handle_connect():
# Subscribe to events
pubsub = self.redis.pubsub()
await pubsub.subscribe("ui_events")
async def update_ui():
async for message in pubsub.listen():
if message["type"] == "message":
event = json.loads(message["data"])
# Update UI
status_label.text = f"Status: {event['type']}"
data_table.add_rows([[
datetime.now().strftime("%H:%M:%S"),
event["type"],
json.dumps(event["payload"])
]])
asyncio.create_task(update_ui())
return ui.column([status_label, data_table])
Choose Your Broker: Decision Matrix¶
| Broker | Use Case | Strengths | Weaknesses | Best For |
|---|---|---|---|---|
| Kafka | High throughput, durability | High throughput, durability, partitioning | Complex setup, resource intensive | IoT pipelines, event sourcing, data lakes |
| Redis Streams | Real-time, lightweight | Fast, simple, integrated with Redis | Memory-bound, limited retention | Real-time dashboards, coordination, caching |
| NATS/JetStream | Lightweight, cloud-native | Simple, fast, cloud-native | Less mature ecosystem | Microservices, command/control, sensor data |
| MQTT | IoT, constrained devices | Lightweight, QoS levels, LWT | Limited features | IoT devices, sensor networks |
| Postgres LISTEN/NOTIFY | Database-driven events | Integrated, transactional | Limited scalability | Database change events, triggers |
Reference Architecture¶
graph TB
subgraph Producers["Event Producers"]
API["FastAPI API"]
ETL["ETL Pipelines"]
GIS["GIS Services"]
IoT["IoT Devices"]
end
subgraph Brokers["Message Brokers"]
Kafka["Kafka<br/>(High Throughput)"]
Redis["Redis Streams<br/>(Real-Time)"]
NATS["NATS JetStream<br/>(Lightweight)"]
MQTT["MQTT<br/>(IoT)"]
end
subgraph Processing["Event Processing"]
Workers["Worker Pools"]
Prefect["Prefect Flows"]
ML["ML Inference"]
end
subgraph Storage["Storage"]
Postgres["Postgres<br/>(Event Store)"]
Timescale["TimescaleDB<br/>(Time Series)"]
PostGIS["PostGIS<br/>(Spatial)"]
end
subgraph Delivery["Event Delivery"]
UI["NiceGUI UI<br/>(WebSocket)"]
API2["REST API"]
CDN["CDN"]
end
API --> Kafka
ETL --> Redis
GIS --> NATS
IoT --> MQTT
Kafka --> Workers
Redis --> Prefect
NATS --> ML
MQTT --> Workers
Workers --> Postgres
Prefect --> Timescale
ML --> PostGIS
Postgres --> UI
Timescale --> API2
PostGIS --> CDN
style Producers fill:#e1f5ff
style Brokers fill:#fff4e1
style Processing fill:#e8f5e9
style Storage fill:#f3e5f5
style Delivery fill:#ffebee Checklists¶
Event-Driven System Design Checklist¶
- Choose appropriate broker(s) for use case
- Design event contracts with versioning
- Implement idempotency for all consumers
- Set up dead-letter queues
- Configure retry policies
- Design for backpressure
- Plan for schema evolution
- Implement event signing/verification
- Set up observability (metrics, logs, traces)
- Design failure recovery procedures
Deployment Checklist¶
- Configure broker persistence/durability
- Set up consumer groups
- Configure partitioning strategy
- Set up monitoring and alerting
- Configure security (mTLS, ACLs)
- Test failure scenarios
- Document recovery procedures
- Set up capacity planning
Operational Checklist¶
- Monitor consumer lag
- Track message throughput
- Monitor broker health
- Check for backpressure
- Review DLQ regularly
- Audit event processing
- Review and update schemas
- Test disaster recovery
See Also¶
- System Resilience - Resilience patterns
- Configuration Management - Config governance
- Release Management - Deployment practices
This guide provides a complete framework for event-driven architecture. Start with the right broker for your use case, design solid event contracts, implement proper observability, and plan for failure. The goal is resilient, scalable event-driven systems that can evolve independently.