AF

AgentFlow — Diseño del Event Store

Arquitectura de Event Sourcing sobre PostgreSQL (Supabase) para orquestacion de agentes IA

Arquitectura Tecnica v1.0
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
Posicion global — log unificado
#1
AgentCreated
#2
CampaignCreated
#3
PromptConfigured
#4
AudienceSet
#5
ToolsAssigned
#6
SessionStarted
...
continuando
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
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_id y causation_id en 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_position para 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_at en 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.