8K
Eventos/dia actual
200K
Proyeccion 12 meses
<50ms
Latencia P99 objetivo
3
Tipos de stream
Arquitectura del Event Store
Estructura de Streams — AgentFlow
Cada agregado tiene su propio stream. Los eventos son inmutables y se ordenan por posicion global.
Agent-{uuid}
v1
AgentCreated
#1
v2
PromptConfigured
#3
v3
ToolsAssigned
#5
v4
AgentActivated
#8
v5
AgentPaused
#12
Campaign-{uuid}
v1
CampaignCreated
#2
v2
AudienceSet
#4
v3
CampaignLaunched
#7
v4
LeadConverted
#10
v5
CampaignCompleted
#14
Interaction-{uuid}
v1
SessionStarted
#6
v2
MessageSent
#9
v3
ResponseReceived
#11
v4
IntentClassified
#13
v5
SessionClosed
#15
Esquema PostgreSQL (Supabase)
schema_agentflow_events.sql — Esquema de produccion
SQL
-- ============================================================ -- AgentFlow Event Store — PostgreSQL / Supabase -- Cumplimiento GDPR: anonimizacion sin borrar eventos -- ============================================================ -- Tabla principal de eventos (inmutable, append-only) CREATE TABLE agentflow_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), stream_id VARCHAR(255) NOT NULL, -- e.g. "Agent-550e8400" stream_type VARCHAR(64) NOT NULL, -- Agent | Campaign | Interaction event_type VARCHAR(128) NOT NULL, -- AgentCreated, LeadConverted... event_data JSONB NOT NULL, -- payload del evento metadata JSONB DEFAULT '{}', -- correlation_id, user_id, trace_id version BIGINT NOT NULL, -- version dentro del stream global_position BIGSERIAL, -- posicion global (orden total) created_at TIMESTAMPTZ DEFAULT NOW(), -- GDPR: reemplazar PII sin mutar el evento anonymized_at TIMESTAMPTZ DEFAULT NULL, anonymized_by UUID DEFAULT NULL, CONSTRAINT uq_stream_version UNIQUE (stream_id, version), -- Evita doble escritura con el mismo event_id CONSTRAINT uq_event_id UNIQUE (id) ); -- Indices criticos para AgentFlow CREATE INDEX idx_stream ON agentflow_events(stream_id, version); CREATE INDEX idx_global_position ON agentflow_events(global_position); CREATE INDEX idx_event_type ON agentflow_events(event_type); CREATE INDEX idx_stream_type ON agentflow_events(stream_type, global_position); CREATE INDEX idx_created_at ON agentflow_events(created_at); -- Snapshots — para agentes con muchos eventos (>200) CREATE TABLE agentflow_snapshots ( stream_id VARCHAR(255) PRIMARY KEY, stream_type VARCHAR(64) NOT NULL, snapshot_data JSONB NOT NULL, version BIGINT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Checkpoints para subscriptores (projections, read models) CREATE TABLE agentflow_checkpoints ( subscription_id VARCHAR(128) PRIMARY KEY, last_position BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ DEFAULT NOW() );
event_store.py — Implementacion FastAPI/asyncpg lista para produccion
Python
from dataclasses import dataclass, field from datetime import datetime from typing import Optional, List from uuid import UUID, uuid4 import json, asyncpg @dataclass class AgentFlowEvent: stream_id: str event_type: str # AgentCreated, LeadConverted... data: dict metadata: dict = field(default_factory=dict) event_id: UUID = field(default_factory=uuid4) version: Optional[int] = None global_position: Optional[int] = None created_at: datetime = field(default_factory=datetime.utcnow) class AgentFlowEventStore: """Event Store para AgentFlow. append-only, inmutable, GDPR-ready.""" def __init__(self, pool: asyncpg.Pool): self.pool = pool async def append( self, stream_id: str, # "Agent-550e8400-e29b..." stream_type: str, # "Agent" | "Campaign" | "Interaction" events: List[AgentFlowEvent], expected_version: Optional[int] = None ) -> List[AgentFlowEvent]: async with self.pool.acquire() as conn: async with conn.transaction(): # Optimistic concurrency check if expected_version is not None: current = await conn.fetchval( "SELECT COALESCE(MAX(version),0) FROM agentflow_events WHERE stream_id=$1", stream_id ) if current != expected_version: raise ConcurrencyError( f"Stream {stream_id}: expected v{expected_version}, got v{current}" ) start = await conn.fetchval( "SELECT COALESCE(MAX(version),0)+1 FROM agentflow_events WHERE stream_id=$1", stream_id ) saved = [] for i, ev in enumerate(events): ev.version = start + i row = await conn.fetchrow( """INSERT INTO agentflow_events (id, stream_id, stream_type, event_type, event_data, metadata, version, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING global_position""", ev.event_id, stream_id, stream_type, ev.event_type, json.dumps(ev.data), json.dumps(ev.metadata), ev.version, ev.created_at ) ev.global_position = row['global_position'] saved.append(ev) return saved async def read_stream( self, stream_id: str, from_version: int = 0 ) -> List[AgentFlowEvent]: async with self.pool.acquire() as conn: rows = await conn.fetch( """SELECT * FROM agentflow_events WHERE stream_id=$1 AND version>=$2 ORDER BY version""", stream_id, from_version ) return [self._from_row(r) for r in rows] async def anonymize(self, stream_id: str, by_user: UUID): """GDPR: reemplaza PII en event_data sin borrar el evento.""" async with self.pool.acquire() as conn: await conn.execute( """UPDATE agentflow_events SET event_data = event_data - 'email' - 'phone' - 'name' || '{"_anonymized": true}'::jsonb, anonymized_at = NOW(), anonymized_by = $2 WHERE stream_id=$1""", stream_id, by_user ) class ConcurrencyError(Exception): pass
Comparativa de tecnologias
| Tecnologia | Mejor para | Limitaciones | Coste mensual | Para AgentFlow |
|---|---|---|---|---|
PostgreSQL (Supabase) Stack actual |
Equipos con Postgres existente. ACID + JSONB. Queries flexibles. | Implementacion manual. No tiene subscripciones nativas. | Ya incluido | RECOMENDADO |
EventStoreDB |
Event sourcing puro. Subscripciones nativas. Proyecciones del lado del servidor. | Licencia de pago para produccion. Un sistema mas a operar. | ~$200/mes | Overkill |
Kafka |
Streaming de alto volumen. Millones de eventos/dia. Multiples consumidores. | No ideal para queries por stream. Complejidad operativa alta. | ~$150/mes | Prematuro |
DynamoDB |
Serverless AWS-native. Escala automatica sin gestion. | Queries limitadas. Sin transacciones multi-stream. Vendor lock-in. | ~$50/mes | Fase 2 |
Patron Snapshot (agentes larga vida)
Cuando un agente supera 200 eventos
Sin snapshots, reconstruir el estado de un agente requiere leer todos sus eventos desde el inicio. Con snapshot, solo lees desde la ultima fotografia.
v1: AgentCreated
→
v2..v49
→
SNAPSHOT v50
→
v51..v100
→
SNAPSHOT v100
Reconstruccion: cargar snapshot v100 + eventos v101 en adelante. Latencia: O(1) en vez de O(n).
Metadatos estandar por evento
Estructura metadata
JSON
{
"event_type": "LeadConverted",
"stream_id": "Campaign-a1b2c3",
"version": 4,
"global_position": 10,
"data": {
"lead_id": "lead-789",
"value_eur": 1200,
"channel": "linkedin"
},
"metadata": {
"correlation_id": "req-uuid-xxx",
"causation_id": "event-uuid-yyy",
"user_id": "user-42",
"trace_id": "otel-trace-zzz",
"schema_version": 1
}
}
Buenas practicas
Do — Siempre hacer
- Incluir
correlation_idycausation_iden metadata - Usar stream IDs con tipo:
Agent-{uuid} - Versionar esquema de eventos desde el dia 1
- Implementar idempotencia con event_id unico
- Hacer snapshots cuando un stream supera 200 eventos
- Indexar por
global_positionpara subscripciones
Don't — Nunca hacer
- Actualizar o borrar eventos existentes
- Guardar payloads grandes (>16KB) en event_data
- Saltar el control de concurrencia optimista
- Ignorar backpressure en consumidores lentos
- Guardar PII en texto plano (GDPR)
- Mezclar logica de dominio con infraestructura del store
GDPR — Anonimizacion
- Nunca borrar eventos, reemplazar los campos PII
- Usar el metodo
anonymize()con registro de quién y cuándo - Guardar email/phone en metadata, no en event_data
- Separar PII en tabla auxiliar criptografiable
- Mantener
anonymized_aten cada evento tratado - Auditar accesos con
anonymized_by
Decision final para AgentFlow
PostgreSQL (Supabase) — Eleccion correcta
Con 8K eventos/dia actuales y proyeccion de 200K en 12 meses, PostgreSQL sobre Supabase cubre perfectamente el volumen sin coste adicional. La implementacion con asyncpg + control de concurrencia optimista garantiza la consistencia. El patron de snapshots cada 200 eventos mantiene la reconstruccion de agentes por debajo de la latencia P99 objetivo de 50ms. La columna anonymized_at cubre el cumplimiento GDPR sin borrar el historial. Migrar a DynamoDB o Kafka solo tiene sentido cuando superen 5M eventos/dia.