swl-ses 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +425 -0
- package/_userland/agentes/.gitkeep +0 -0
- package/_userland/habilidades/.gitkeep +0 -0
- package/agentes/accesibilidad-wcag-swl.md +683 -0
- package/agentes/arquitecto-swl.md +210 -0
- package/agentes/auto-evolucion-swl.md +408 -0
- package/agentes/backend-api-swl.md +442 -0
- package/agentes/backend-node-swl.md +439 -0
- package/agentes/backend-python-swl.md +469 -0
- package/agentes/backend-workers-swl.md +444 -0
- package/agentes/cloud-infra-swl.md +466 -0
- package/agentes/consolidador-swl.md +487 -0
- package/agentes/datos-swl.md +568 -0
- package/agentes/depurador-swl.md +301 -0
- package/agentes/devops-ci-swl.md +352 -0
- package/agentes/disenador-ui-swl.md +546 -0
- package/agentes/documentador-swl.md +323 -0
- package/agentes/frontend-angular-swl.md +603 -0
- package/agentes/frontend-css-swl.md +700 -0
- package/agentes/frontend-react-swl.md +672 -0
- package/agentes/frontend-swl.md +483 -0
- package/agentes/frontend-tailwind-swl.md +808 -0
- package/agentes/implementador-swl.md +235 -0
- package/agentes/investigador-swl.md +274 -0
- package/agentes/investigador-ux-swl.md +482 -0
- package/agentes/migrador-swl.md +389 -0
- package/agentes/mobile-android-swl.md +473 -0
- package/agentes/mobile-cross-swl.md +501 -0
- package/agentes/mobile-ios-swl.md +464 -0
- package/agentes/notificador-swl.md +886 -0
- package/agentes/observabilidad-swl.md +408 -0
- package/agentes/orquestador-swl.md +490 -0
- package/agentes/planificador-swl.md +222 -0
- package/agentes/producto-prd-swl.md +565 -0
- package/agentes/release-manager-swl.md +545 -0
- package/agentes/rendimiento-swl.md +691 -0
- package/agentes/revisor-codigo-swl.md +254 -0
- package/agentes/revisor-seguridad-swl.md +316 -0
- package/agentes/tdd-qa-swl.md +323 -0
- package/agentes/ux-disenador-swl.md +498 -0
- package/bin/swl-ses.js +119 -0
- package/comandos/swl/actualizar.md +117 -0
- package/comandos/swl/aprender.md +348 -0
- package/comandos/swl/auditar-deps.md +390 -0
- package/comandos/swl/autoresearch.md +346 -0
- package/comandos/swl/checkpoint.md +296 -0
- package/comandos/swl/compactar.md +283 -0
- package/comandos/swl/crear-skill.md +609 -0
- package/comandos/swl/discutir-fase.md +230 -0
- package/comandos/swl/ejecutar-fase.md +302 -0
- package/comandos/swl/evolucionar.md +377 -0
- package/comandos/swl/instalar.md +220 -0
- package/comandos/swl/mapear-codebase.md +205 -0
- package/comandos/swl/nuevo-proyecto.md +154 -0
- package/comandos/swl/planear-fase.md +221 -0
- package/comandos/swl/release.md +405 -0
- package/comandos/swl/salud.md +382 -0
- package/comandos/swl/verificar.md +292 -0
- package/habilidades/accesibilidad-a11y/SKILL.md +584 -0
- package/habilidades/angular-avanzado/SKILL.md +491 -0
- package/habilidades/angular-moderno/SKILL.md +326 -0
- package/habilidades/api-rest-diseno/SKILL.md +302 -0
- package/habilidades/api-rest-diseno/recursos/openapi-template.yaml +506 -0
- package/habilidades/aprendizaje-continuo/SKILL.md +369 -0
- package/habilidades/async-python/SKILL.md +474 -0
- package/habilidades/auth-patrones/SKILL.md +488 -0
- package/habilidades/auto-evolucion-protocolo/SKILL.md +376 -0
- package/habilidades/autoresearch/SKILL.md +248 -0
- package/habilidades/autoresearch/recursos/checklist-template.md +191 -0
- package/habilidades/autoresearch/scripts/calcular-score.js +88 -0
- package/habilidades/checklist-calidad/SKILL.md +247 -0
- package/habilidades/checklist-calidad/recursos/quality-report-template.md +148 -0
- package/habilidades/checklist-seguridad/SKILL.md +224 -0
- package/habilidades/checkpoints-verificacion/SKILL.md +309 -0
- package/habilidades/checkpoints-verificacion/recursos/checkpoint-templates.md +360 -0
- package/habilidades/ci-cd-pipelines/SKILL.md +583 -0
- package/habilidades/ci-cd-pipelines/recursos/github-actions-template.yaml +403 -0
- package/habilidades/cloud-aws/SKILL.md +497 -0
- package/habilidades/compactacion-contexto/SKILL.md +201 -0
- package/habilidades/contenedores-docker/SKILL.md +453 -0
- package/habilidades/contenedores-docker/recursos/dockerfile-template.dockerfile +160 -0
- package/habilidades/css-moderno/SKILL.md +463 -0
- package/habilidades/datos-etl/SKILL.md +486 -0
- package/habilidades/dependencias-auditoria/SKILL.md +293 -0
- package/habilidades/deprecacion-migracion/SKILL.md +485 -0
- package/habilidades/design-tokens/SKILL.md +519 -0
- package/habilidades/discutir-fase/SKILL.md +167 -0
- package/habilidades/diseno-responsivo/SKILL.md +326 -0
- package/habilidades/django-experto/SKILL.md +395 -0
- package/habilidades/doc-sync/SKILL.md +259 -0
- package/habilidades/ejecutar-fase/SKILL.md +199 -0
- package/habilidades/estructura-proyecto-claude/SKILL.md +459 -0
- package/habilidades/estructura-proyecto-claude/recursos/claude-md-template.md +261 -0
- package/habilidades/estructura-proyecto-claude/recursos/frontmatter-y-hooks-referencia.md +213 -0
- package/habilidades/estructura-proyecto-claude/recursos/mcp-json-template.json +77 -0
- package/habilidades/estructura-proyecto-claude/recursos/variantes-por-stack.md +177 -0
- package/habilidades/event-driven/SKILL.md +580 -0
- package/habilidades/extractor-de-aprendizajes/SKILL.md +234 -0
- package/habilidades/fastapi-experto/SKILL.md +368 -0
- package/habilidades/frontend-avanzado/SKILL.md +555 -0
- package/habilidades/git-worktrees-paralelo/SKILL.md +246 -0
- package/habilidades/iam-secretos/SKILL.md +511 -0
- package/habilidades/instalar-sistema/SKILL.md +140 -0
- package/habilidades/kubernetes-orquestacion/SKILL.md +549 -0
- package/habilidades/manejo-errores/SKILL.md +512 -0
- package/habilidades/mapear-codebase/SKILL.md +199 -0
- package/habilidades/microservicios/SKILL.md +473 -0
- package/habilidades/mobile-flutter/SKILL.md +566 -0
- package/habilidades/mobile-react-native/SKILL.md +493 -0
- package/habilidades/monitoring-alertas/SKILL.md +447 -0
- package/habilidades/node-experto/SKILL.md +521 -0
- package/habilidades/notificaciones-multicanal/SKILL.md +448 -0
- package/habilidades/notificaciones-multicanal/recursos/config-template.json +115 -0
- package/habilidades/nuevo-proyecto/SKILL.md +183 -0
- package/habilidades/patrones-python/SKILL.md +381 -0
- package/habilidades/performance-baseline/SKILL.md +243 -0
- package/habilidades/planear-fase/SKILL.md +184 -0
- package/habilidades/postgresql-experto/SKILL.md +379 -0
- package/habilidades/react-experto/SKILL.md +434 -0
- package/habilidades/react-optimizacion/SKILL.md +328 -0
- package/habilidades/release-semver/SKILL.md +226 -0
- package/habilidades/release-semver/scripts/generar-changelog.sh +238 -0
- package/habilidades/sql-optimizacion/SKILL.md +314 -0
- package/habilidades/tailwind-experto/SKILL.md +412 -0
- package/habilidades/tdd-workflow/SKILL.md +267 -0
- package/habilidades/testing-python/SKILL.md +350 -0
- package/habilidades/threat-model-lite/SKILL.md +218 -0
- package/habilidades/typescript-avanzado/SKILL.md +454 -0
- package/habilidades/ux-diseno/SKILL.md +488 -0
- package/habilidades/validacion-ci-sistema/SKILL.md +543 -0
- package/habilidades/validacion-ci-sistema/scripts/validar-sistema.sh +286 -0
- package/habilidades/verificar-trabajo/SKILL.md +208 -0
- package/habilidades/wireframes-flujos/SKILL.md +396 -0
- package/habilidades/workflow-claude-code/SKILL.md +359 -0
- package/hooks/calidad-pre-commit.js +578 -0
- package/hooks/escaneo-secretos.js +302 -0
- package/hooks/extraccion-aprendizajes.js +550 -0
- package/hooks/linea-estado.js +249 -0
- package/hooks/monitor-contexto.js +230 -0
- package/hooks/proteccion-rutas.js +249 -0
- package/manifiestos/hooks-config.json +41 -0
- package/manifiestos/modulos.json +318 -0
- package/manifiestos/perfiles.json +189 -0
- package/package.json +45 -0
- package/plantillas/PROJECT.md +122 -0
- package/plantillas/REQUIREMENTS.md +132 -0
- package/plantillas/ROADMAP.md +143 -0
- package/plantillas/STATE.md +109 -0
- package/plantillas/research/ARCHITECTURE.md +220 -0
- package/plantillas/research/FEATURES.md +175 -0
- package/plantillas/research/PITFALLS.md +299 -0
- package/plantillas/research/STACK.md +233 -0
- package/plantillas/research/SUMMARY.md +165 -0
- package/plugin.json +144 -0
- package/reglas/accesibilidad.md +269 -0
- package/reglas/api-diseno.md +400 -0
- package/reglas/arquitectura.md +183 -0
- package/reglas/cloud-infra.md +247 -0
- package/reglas/docs.md +245 -0
- package/reglas/estilo-codigo.md +179 -0
- package/reglas/git-workflow.md +186 -0
- package/reglas/performance.md +195 -0
- package/reglas/pruebas.md +159 -0
- package/reglas/seguridad.md +151 -0
- package/reglas/skills-estandar.md +473 -0
- package/scripts/actualizar.js +51 -0
- package/scripts/desinstalar.js +86 -0
- package/scripts/doctor.js +222 -0
- package/scripts/inicializar.js +89 -0
- package/scripts/instalador.js +333 -0
- package/scripts/lib/detectar-runtime.js +177 -0
- package/scripts/lib/estado.js +112 -0
- package/scripts/lib/hooks-settings.js +283 -0
- package/scripts/lib/manifiestos.js +138 -0
- package/scripts/lib/seguridad.js +160 -0
- package/scripts/publicar.js +209 -0
- package/scripts/validar.js +120 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: event-driven
|
|
3
|
+
description: Arquitectura event-driven. Message brokers RabbitMQ y Kafka, event sourcing, CQRS, pub/sub, event schemas, idempotency, dead letter queues, retry strategies, eventual consistency. Con ejemplos Python.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Arquitectura Orientada a Eventos
|
|
7
|
+
|
|
8
|
+
Los sistemas event-driven desacoplan productores de consumidores, permitiendo escalar
|
|
9
|
+
componentes independientemente y resilir ante fallos. Este skill cubre desde pub/sub
|
|
10
|
+
básico hasta event sourcing avanzado.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Cuándo Usar Event-Driven
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
USAR cuando:
|
|
18
|
+
- Múltiples consumidores necesitan reaccionar al mismo evento
|
|
19
|
+
- El productor no necesita respuesta inmediata del consumidor
|
|
20
|
+
- Necesitas escalar productores y consumidores independientemente
|
|
21
|
+
- Quieres desacoplar servicios que hoy están fuertemente acoplados
|
|
22
|
+
- Necesitas auditoría completa de cambios (event log = audit log)
|
|
23
|
+
|
|
24
|
+
NO USAR cuando:
|
|
25
|
+
- Necesitas respuesta síncrona inmediata para el usuario
|
|
26
|
+
- El flujo es simple y lineal (no hay múltiples consumidores)
|
|
27
|
+
- El equipo no tiene experiencia operando brokers de mensajes
|
|
28
|
+
- La consistencia eventual no es aceptable para el dominio
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 2. RabbitMQ — Pub/Sub con Python
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# pip install aio-pika
|
|
37
|
+
import aio_pika
|
|
38
|
+
import asyncio
|
|
39
|
+
import json
|
|
40
|
+
from dataclasses import dataclass, asdict
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from typing import Any, Callable, Awaitable
|
|
43
|
+
import uuid
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Evento:
|
|
47
|
+
tipo: str
|
|
48
|
+
payload: dict
|
|
49
|
+
id: str = ""
|
|
50
|
+
timestamp: str = ""
|
|
51
|
+
correlation_id: str = ""
|
|
52
|
+
version: str = "1.0"
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if not self.id:
|
|
56
|
+
self.id = str(uuid.uuid4())
|
|
57
|
+
if not self.timestamp:
|
|
58
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
59
|
+
|
|
60
|
+
class BrokerEventos:
|
|
61
|
+
"""Wrapper sobre aio_pika para publicar y consumir eventos."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str):
|
|
64
|
+
self._url = url
|
|
65
|
+
self._conexion: aio_pika.RobustConnection | None = None
|
|
66
|
+
self._canal: aio_pika.abc.AbstractChannel | None = None
|
|
67
|
+
|
|
68
|
+
async def conectar(self) -> None:
|
|
69
|
+
self._conexion = await aio_pika.connect_robust(
|
|
70
|
+
self._url,
|
|
71
|
+
heartbeat=60,
|
|
72
|
+
reconnect_interval=5.0,
|
|
73
|
+
)
|
|
74
|
+
self._canal = await self._conexion.channel()
|
|
75
|
+
await self._canal.set_qos(prefetch_count=10) # Procesar 10 mensajes a la vez
|
|
76
|
+
|
|
77
|
+
async def publicar(
|
|
78
|
+
self,
|
|
79
|
+
evento: Evento,
|
|
80
|
+
exchange: str = "eventos.rrhh",
|
|
81
|
+
routing_key: str | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Publicar evento al exchange con routing key = tipo del evento."""
|
|
84
|
+
assert self._canal, "Llamar connect() primero"
|
|
85
|
+
|
|
86
|
+
exchange_obj = await self._canal.declare_exchange(
|
|
87
|
+
exchange,
|
|
88
|
+
aio_pika.ExchangeType.TOPIC,
|
|
89
|
+
durable=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await exchange_obj.publish(
|
|
93
|
+
aio_pika.Message(
|
|
94
|
+
body=json.dumps(asdict(evento), ensure_ascii=False).encode(),
|
|
95
|
+
content_type="application/json",
|
|
96
|
+
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, # Sobrevive restart
|
|
97
|
+
message_id=evento.id,
|
|
98
|
+
timestamp=datetime.now(timezone.utc),
|
|
99
|
+
headers={
|
|
100
|
+
"x-correlation-id": evento.correlation_id,
|
|
101
|
+
"x-event-version": evento.version,
|
|
102
|
+
},
|
|
103
|
+
),
|
|
104
|
+
routing_key=routing_key or evento.tipo, # "empleado.creado"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def suscribir(
|
|
108
|
+
self,
|
|
109
|
+
cola: str,
|
|
110
|
+
exchange: str,
|
|
111
|
+
routing_keys: list[str],
|
|
112
|
+
handler: Callable[[Evento], Awaitable[None]],
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Suscribir a eventos con manejo automático de DLQ."""
|
|
115
|
+
assert self._canal
|
|
116
|
+
|
|
117
|
+
# Dead Letter Queue — eventos que fallan tras N reintentos
|
|
118
|
+
dlq_nombre = f"{cola}.dlq"
|
|
119
|
+
await self._canal.declare_queue(
|
|
120
|
+
dlq_nombre,
|
|
121
|
+
durable=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Cola principal con referencia a DLQ
|
|
125
|
+
queue = await self._canal.declare_queue(
|
|
126
|
+
cola,
|
|
127
|
+
durable=True,
|
|
128
|
+
arguments={
|
|
129
|
+
"x-dead-letter-exchange": "",
|
|
130
|
+
"x-dead-letter-routing-key": dlq_nombre,
|
|
131
|
+
"x-message-ttl": 86400000, # 24h en ms
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
exchange_obj = await self._canal.declare_exchange(
|
|
136
|
+
exchange, aio_pika.ExchangeType.TOPIC, durable=True
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for routing_key in routing_keys:
|
|
140
|
+
await queue.bind(exchange_obj, routing_key=routing_key)
|
|
141
|
+
|
|
142
|
+
async def procesar_mensaje(mensaje: aio_pika.IncomingMessage) -> None:
|
|
143
|
+
async with mensaje.process(requeue=False): # requeue=False → DLQ al fallar
|
|
144
|
+
try:
|
|
145
|
+
datos = json.loads(mensaje.body)
|
|
146
|
+
evento = Evento(**datos)
|
|
147
|
+
await handler(evento)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error("Error procesando evento %s: %s", mensaje.message_id, e)
|
|
150
|
+
raise # Enviar a DLQ
|
|
151
|
+
|
|
152
|
+
await queue.consume(procesar_mensaje)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 3. Kafka — Streaming de Alta Escala
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# pip install aiokafka
|
|
161
|
+
from aiokafka import AIOKafkaProducer, AIOKafkaConsumer
|
|
162
|
+
from aiokafka.errors import KafkaConnectionError
|
|
163
|
+
import asyncio
|
|
164
|
+
import json
|
|
165
|
+
|
|
166
|
+
class ProductorKafka:
|
|
167
|
+
"""Productor Kafka idempotente con reintentos."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, bootstrap_servers: str):
|
|
170
|
+
self._producer = AIOKafkaProducer(
|
|
171
|
+
bootstrap_servers=bootstrap_servers,
|
|
172
|
+
enable_idempotence=True, # Garantiza exactamente-una-vez
|
|
173
|
+
max_batch_size=65536, # 64KB por batch
|
|
174
|
+
compression_type="gzip",
|
|
175
|
+
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode(),
|
|
176
|
+
key_serializer=lambda k: k.encode() if k else None,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def __aenter__(self) -> "ProductorKafka":
|
|
180
|
+
await self._producer.start()
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
184
|
+
await self._producer.stop()
|
|
185
|
+
|
|
186
|
+
async def enviar(
|
|
187
|
+
self,
|
|
188
|
+
topico: str,
|
|
189
|
+
evento: dict,
|
|
190
|
+
clave: str | None = None, # Misma clave → mismo partition → orden garantizado
|
|
191
|
+
) -> None:
|
|
192
|
+
await self._producer.send_and_wait(
|
|
193
|
+
topico,
|
|
194
|
+
value=evento,
|
|
195
|
+
key=clave,
|
|
196
|
+
headers=[
|
|
197
|
+
("content-type", b"application/json"),
|
|
198
|
+
("event-id", evento.get("id", "").encode()),
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
class ConsumidorKafka:
|
|
203
|
+
"""Consumidor Kafka con manejo de errores y commit manual."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, bootstrap_servers: str, group_id: str, topicos: list[str]):
|
|
206
|
+
self._consumer = AIOKafkaConsumer(
|
|
207
|
+
*topicos,
|
|
208
|
+
bootstrap_servers=bootstrap_servers,
|
|
209
|
+
group_id=group_id,
|
|
210
|
+
auto_offset_reset="earliest",
|
|
211
|
+
enable_auto_commit=False, # Commit manual después de procesar
|
|
212
|
+
max_poll_records=100,
|
|
213
|
+
session_timeout_ms=30000,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def consumir(
|
|
217
|
+
self,
|
|
218
|
+
handler: Callable[[dict], Awaitable[None]],
|
|
219
|
+
) -> None:
|
|
220
|
+
await self._consumer.start()
|
|
221
|
+
try:
|
|
222
|
+
async for mensaje in self._consumer:
|
|
223
|
+
try:
|
|
224
|
+
datos = json.loads(mensaje.value)
|
|
225
|
+
await handler(datos)
|
|
226
|
+
await self._consumer.commit() # Commit SOLO si procesó exitosamente
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.error(
|
|
229
|
+
"Error en topico=%s partition=%s offset=%s: %s",
|
|
230
|
+
mensaje.topic, mensaje.partition, mensaje.offset, e,
|
|
231
|
+
)
|
|
232
|
+
# Dependiendo de la política: commit igualmente (skip) o no (retry)
|
|
233
|
+
finally:
|
|
234
|
+
await self._consumer.stop()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 4. Schemas de Eventos — Contrato entre servicios
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
# SIEMPRE definir un schema explícito para cada tipo de evento
|
|
243
|
+
# Los schemas son el contrato entre productor y consumidores
|
|
244
|
+
|
|
245
|
+
from pydantic import BaseModel, field_validator
|
|
246
|
+
from datetime import datetime
|
|
247
|
+
from typing import Literal
|
|
248
|
+
import uuid
|
|
249
|
+
|
|
250
|
+
class EventoBase(BaseModel):
|
|
251
|
+
"""Base para todos los eventos del sistema."""
|
|
252
|
+
id: str = ""
|
|
253
|
+
timestamp: datetime = None
|
|
254
|
+
version: str = "1.0"
|
|
255
|
+
correlation_id: str = ""
|
|
256
|
+
origen: str # Nombre del servicio que genera el evento
|
|
257
|
+
|
|
258
|
+
def model_post_init(self, *args):
|
|
259
|
+
if not self.id:
|
|
260
|
+
self.id = str(uuid.uuid4())
|
|
261
|
+
if not self.timestamp:
|
|
262
|
+
self.timestamp = datetime.now(timezone.utc)
|
|
263
|
+
|
|
264
|
+
# Evento de dominio: empleado.creado
|
|
265
|
+
class EmpleadoCreadoPayload(BaseModel):
|
|
266
|
+
empleado_id: str
|
|
267
|
+
numero_empleado: str
|
|
268
|
+
nombre_completo: str
|
|
269
|
+
email_corporativo: str
|
|
270
|
+
departamento_id: str
|
|
271
|
+
puesto: str
|
|
272
|
+
fecha_ingreso: str # ISO 8601
|
|
273
|
+
|
|
274
|
+
class EventoEmpleadoCreado(EventoBase):
|
|
275
|
+
tipo: Literal["empleado.creado"] = "empleado.creado"
|
|
276
|
+
payload: EmpleadoCreadoPayload
|
|
277
|
+
|
|
278
|
+
# Versioning de schemas — NUNCA romper compatibilidad hacia atrás
|
|
279
|
+
class EmpleadoCreadoPayloadV2(EmpleadoCreadoPayload):
|
|
280
|
+
"""v2 agrega campos opcionales — compatible con consumidores de v1."""
|
|
281
|
+
nss: str | None = None # Nuevo campo opcional
|
|
282
|
+
curp: str | None = None
|
|
283
|
+
|
|
284
|
+
class EventoEmpleadoCreadoV2(EventoBase):
|
|
285
|
+
tipo: Literal["empleado.creado"] = "empleado.creado"
|
|
286
|
+
version: str = "2.0"
|
|
287
|
+
payload: EmpleadoCreadoPayloadV2
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## 5. Idempotency — Procesar exactamente una vez
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
import hashlib
|
|
296
|
+
from datetime import datetime, timezone
|
|
297
|
+
|
|
298
|
+
class RegistroIdempotencia:
|
|
299
|
+
"""Previene procesamiento duplicado de eventos."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, redis_client):
|
|
302
|
+
self._redis = redis_client
|
|
303
|
+
self._ttl = 86400 # 24 horas
|
|
304
|
+
|
|
305
|
+
async def ya_procesado(self, evento_id: str, consumer_id: str) -> bool:
|
|
306
|
+
clave = f"idempotencia:{consumer_id}:{evento_id}"
|
|
307
|
+
return bool(await self._redis.exists(clave))
|
|
308
|
+
|
|
309
|
+
async def marcar_procesado(self, evento_id: str, consumer_id: str) -> None:
|
|
310
|
+
clave = f"idempotencia:{consumer_id}:{evento_id}"
|
|
311
|
+
await self._redis.setex(clave, self._ttl, "1")
|
|
312
|
+
|
|
313
|
+
# Uso en handler de eventos
|
|
314
|
+
class HandlerNuevaAlta:
|
|
315
|
+
def __init__(self, repo, idempotencia: RegistroIdempotencia):
|
|
316
|
+
self._repo = repo
|
|
317
|
+
self._idempotencia = idempotencia
|
|
318
|
+
|
|
319
|
+
async def manejar(self, evento: EventoEmpleadoCreado) -> None:
|
|
320
|
+
consumer_id = "handler.nueva_alta.configurar_nomina"
|
|
321
|
+
|
|
322
|
+
# Verificar idempotencia ANTES de procesar
|
|
323
|
+
if await self._idempotencia.ya_procesado(evento.id, consumer_id):
|
|
324
|
+
logger.info("Evento %s ya procesado, ignorando", evento.id)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Procesar el evento
|
|
328
|
+
await self._repo.configurar_nomina(evento.payload)
|
|
329
|
+
|
|
330
|
+
# Marcar como procesado DESPUÉS del procesamiento exitoso
|
|
331
|
+
await self._idempotencia.marcar_procesado(evento.id, consumer_id)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 6. Retry Strategies — Reintentos con Backoff
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
import asyncio
|
|
340
|
+
from datetime import datetime, timezone, timedelta
|
|
341
|
+
|
|
342
|
+
class PoliticaReintento:
|
|
343
|
+
"""Backoff exponencial con jitter para evitar thundering herd."""
|
|
344
|
+
|
|
345
|
+
def __init__(
|
|
346
|
+
self,
|
|
347
|
+
max_intentos: int = 5,
|
|
348
|
+
delay_inicial: float = 1.0,
|
|
349
|
+
delay_maximo: float = 60.0,
|
|
350
|
+
multiplicador: float = 2.0,
|
|
351
|
+
):
|
|
352
|
+
self.max_intentos = max_intentos
|
|
353
|
+
self.delay_inicial = delay_inicial
|
|
354
|
+
self.delay_maximo = delay_maximo
|
|
355
|
+
self.multiplicador = multiplicador
|
|
356
|
+
|
|
357
|
+
def delay_para_intento(self, intento: int) -> float:
|
|
358
|
+
"""Calcula delay con jitter para evitar reintentos sincronizados."""
|
|
359
|
+
import random
|
|
360
|
+
delay = min(
|
|
361
|
+
self.delay_inicial * (self.multiplicador ** (intento - 1)),
|
|
362
|
+
self.delay_maximo,
|
|
363
|
+
)
|
|
364
|
+
# Jitter: ±25% del delay calculado
|
|
365
|
+
jitter = delay * 0.25 * (2 * random.random() - 1)
|
|
366
|
+
return delay + jitter
|
|
367
|
+
|
|
368
|
+
# Dead Letter Queue — reintentos agotados
|
|
369
|
+
class ProcesadorDLQ:
|
|
370
|
+
"""Procesa mensajes en la Dead Letter Queue para análisis y reintento manual."""
|
|
371
|
+
|
|
372
|
+
async def analizar_dlq(self, cola_dlq: str) -> list[dict]:
|
|
373
|
+
"""Obtiene mensajes fallidos para investigación."""
|
|
374
|
+
mensajes = []
|
|
375
|
+
async for mensaje in self._consumer.consumir_dlq(cola_dlq):
|
|
376
|
+
mensajes.append({
|
|
377
|
+
"id": mensaje.message_id,
|
|
378
|
+
"timestamp_original": mensaje.headers.get("x-first-death-time"),
|
|
379
|
+
"motivo_fallo": mensaje.headers.get("x-death")[0]["reason"],
|
|
380
|
+
"intentos": mensaje.headers.get("x-death")[0]["count"],
|
|
381
|
+
"cuerpo": json.loads(mensaje.body),
|
|
382
|
+
})
|
|
383
|
+
return mensajes
|
|
384
|
+
|
|
385
|
+
async def reintentar_mensaje(self, mensaje_id: str) -> None:
|
|
386
|
+
"""Reintento manual después de investigación."""
|
|
387
|
+
mensaje = await self._obtener_de_dlq(mensaje_id)
|
|
388
|
+
await self._publicar_a_cola_original(mensaje)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## 7. CQRS — Command Query Responsibility Segregation
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
# Separar modelos de lectura y escritura
|
|
397
|
+
# Escritura (Commands) → actualiza BD principal → emite evento
|
|
398
|
+
# Lectura (Queries) → consulta vista desnormalizada optimizada para lectura
|
|
399
|
+
|
|
400
|
+
# COMMAND SIDE
|
|
401
|
+
class ComandoCrearEmpleado(BaseModel):
|
|
402
|
+
nombre: str
|
|
403
|
+
apellido_paterno: str
|
|
404
|
+
apellido_materno: str
|
|
405
|
+
email: str
|
|
406
|
+
departamento_id: str
|
|
407
|
+
|
|
408
|
+
class HandlerComandoCrearEmpleado:
|
|
409
|
+
async def manejar(
|
|
410
|
+
self,
|
|
411
|
+
comando: ComandoCrearEmpleado,
|
|
412
|
+
db: AsyncSession,
|
|
413
|
+
broker: BrokerEventos,
|
|
414
|
+
) -> str:
|
|
415
|
+
# Crear en BD normalizada (source of truth)
|
|
416
|
+
empleado = Empleado(**comando.model_dump())
|
|
417
|
+
db.add(empleado)
|
|
418
|
+
await db.flush()
|
|
419
|
+
|
|
420
|
+
# Emitir evento ANTES del commit (transactional outbox pattern)
|
|
421
|
+
evento = EventoEmpleadoCreado(
|
|
422
|
+
origen="servicio-empleados",
|
|
423
|
+
payload=EmpleadoCreadoPayload(
|
|
424
|
+
empleado_id=str(empleado.id),
|
|
425
|
+
nombre_completo=f"{comando.nombre} {comando.apellido_paterno}",
|
|
426
|
+
email_corporativo=comando.email,
|
|
427
|
+
departamento_id=comando.departamento_id,
|
|
428
|
+
numero_empleado=empleado.numero_empleado,
|
|
429
|
+
puesto=empleado.puesto,
|
|
430
|
+
fecha_ingreso=empleado.fecha_ingreso.isoformat(),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
await broker.publicar(evento)
|
|
434
|
+
await db.commit()
|
|
435
|
+
return str(empleado.id)
|
|
436
|
+
|
|
437
|
+
# QUERY SIDE — Vista desnormalizada actualizada por eventos
|
|
438
|
+
class VistaEmpleado(Base):
|
|
439
|
+
"""Tabla de lectura: desnormalizada para queries de UI."""
|
|
440
|
+
__tablename__ = "vista_empleados"
|
|
441
|
+
id = Column(UUID, primary_key=True)
|
|
442
|
+
nombre_completo = Column(String)
|
|
443
|
+
email = Column(String)
|
|
444
|
+
departamento_nombre = Column(String) # Desnormalizado — no requiere JOIN
|
|
445
|
+
puesto = Column(String)
|
|
446
|
+
activo = Column(Boolean, default=True)
|
|
447
|
+
ultima_actualizacion = Column(DateTime)
|
|
448
|
+
|
|
449
|
+
class ActualizadorVistaEmpleados:
|
|
450
|
+
"""Consumidor de eventos que mantiene la vista actualizada."""
|
|
451
|
+
|
|
452
|
+
async def en_empleado_creado(self, evento: EventoEmpleadoCreado) -> None:
|
|
453
|
+
departamento = await self._repo.obtener_departamento(
|
|
454
|
+
evento.payload.departamento_id
|
|
455
|
+
)
|
|
456
|
+
vista = VistaEmpleado(
|
|
457
|
+
id=uuid.UUID(evento.payload.empleado_id),
|
|
458
|
+
nombre_completo=evento.payload.nombre_completo,
|
|
459
|
+
email=evento.payload.email_corporativo,
|
|
460
|
+
departamento_nombre=departamento.nombre,
|
|
461
|
+
puesto=evento.payload.puesto,
|
|
462
|
+
ultima_actualizacion=evento.timestamp,
|
|
463
|
+
)
|
|
464
|
+
await self._db.merge(vista)
|
|
465
|
+
await self._db.commit()
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## 8. Transactional Outbox Pattern
|
|
471
|
+
|
|
472
|
+
```python
|
|
473
|
+
# Problema: publicar evento Y hacer commit en BD de forma atómica
|
|
474
|
+
# Solución: escribir evento a tabla "outbox" en misma transacción,
|
|
475
|
+
# un worker asíncrono publica al broker
|
|
476
|
+
|
|
477
|
+
class EventoOutbox(Base):
|
|
478
|
+
"""Tabla de outbox para publicación garantizada de eventos."""
|
|
479
|
+
__tablename__ = "eventos_outbox"
|
|
480
|
+
id = Column(UUID, primary_key=True, default=uuid.uuid4)
|
|
481
|
+
tipo = Column(String(100), nullable=False)
|
|
482
|
+
payload = Column(JSONB, nullable=False)
|
|
483
|
+
exchange = Column(String(100), nullable=False)
|
|
484
|
+
routing_key = Column(String(100), nullable=False)
|
|
485
|
+
creado_en = Column(DateTime(timezone=True), server_default=func.now())
|
|
486
|
+
publicado_en = Column(DateTime(timezone=True), nullable=True)
|
|
487
|
+
intentos = Column(Integer, default=0)
|
|
488
|
+
|
|
489
|
+
async def crear_empleado_con_outbox(
|
|
490
|
+
comando: ComandoCrearEmpleado,
|
|
491
|
+
db: AsyncSession,
|
|
492
|
+
) -> str:
|
|
493
|
+
"""Crea empleado y registra evento en outbox — todo en una transacción."""
|
|
494
|
+
empleado = Empleado(**comando.model_dump())
|
|
495
|
+
db.add(empleado)
|
|
496
|
+
await db.flush()
|
|
497
|
+
|
|
498
|
+
# Mismo commit = garantía de consistencia
|
|
499
|
+
evento_outbox = EventoOutbox(
|
|
500
|
+
tipo="empleado.creado",
|
|
501
|
+
payload={...},
|
|
502
|
+
exchange="eventos.rrhh",
|
|
503
|
+
routing_key="empleado.creado",
|
|
504
|
+
)
|
|
505
|
+
db.add(evento_outbox)
|
|
506
|
+
await db.commit() # Atómico: empleado + evento en outbox
|
|
507
|
+
|
|
508
|
+
return str(empleado.id)
|
|
509
|
+
|
|
510
|
+
# Worker que publica desde outbox al broker
|
|
511
|
+
async def worker_outbox(db: AsyncSession, broker: BrokerEventos) -> None:
|
|
512
|
+
"""Ejecuta cada 5 segundos, publica eventos pendientes."""
|
|
513
|
+
while True:
|
|
514
|
+
eventos = await db.execute(
|
|
515
|
+
select(EventoOutbox)
|
|
516
|
+
.where(EventoOutbox.publicado_en.is_(None))
|
|
517
|
+
.where(EventoOutbox.intentos < 5)
|
|
518
|
+
.order_by(EventoOutbox.creado_en)
|
|
519
|
+
.limit(100)
|
|
520
|
+
.with_for_update(skip_locked=True) # Para múltiples workers
|
|
521
|
+
)
|
|
522
|
+
for evento in eventos.scalars():
|
|
523
|
+
try:
|
|
524
|
+
await broker.publicar_raw(
|
|
525
|
+
tipo=evento.tipo,
|
|
526
|
+
payload=evento.payload,
|
|
527
|
+
exchange=evento.exchange,
|
|
528
|
+
routing_key=evento.routing_key,
|
|
529
|
+
)
|
|
530
|
+
evento.publicado_en = datetime.now(timezone.utc)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
evento.intentos += 1
|
|
533
|
+
logger.error("Error publicando evento %s: %s", evento.id, e)
|
|
534
|
+
await db.commit()
|
|
535
|
+
await asyncio.sleep(5)
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## 9. Eventual Consistency — Manejo en UI
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
# El frontend debe manejar que los datos pueden no estar actualizados inmediatamente
|
|
544
|
+
|
|
545
|
+
# Backend: responder con estado "aceptado" (202 Accepted)
|
|
546
|
+
@router.post("/empleados", status_code=202)
|
|
547
|
+
async def crear_empleado(comando: ComandoCrearEmpleado, ...):
|
|
548
|
+
empleado_id = await handler.manejar(comando, db, broker)
|
|
549
|
+
return {
|
|
550
|
+
"empleado_id": empleado_id,
|
|
551
|
+
"estado": "procesando",
|
|
552
|
+
"mensaje": "El empleado está siendo procesado. Estará disponible en segundos.",
|
|
553
|
+
"consultar_en": f"/empleados/{empleado_id}/estado",
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
# Polling endpoint para que el frontend verifique
|
|
557
|
+
@router.get("/empleados/{id}/estado")
|
|
558
|
+
async def estado_empleado(id: UUID, db: AsyncSession = Depends(get_db)):
|
|
559
|
+
vista = await db.get(VistaEmpleado, id)
|
|
560
|
+
if vista is None:
|
|
561
|
+
return {"estado": "procesando"}
|
|
562
|
+
return {"estado": "completado", "datos": vista}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
## 10. Checklist de Sistema Event-Driven
|
|
568
|
+
|
|
569
|
+
- [ ] Schemas de eventos definidos con Pydantic (tipado fuerte)
|
|
570
|
+
- [ ] Versioning en schemas (campo `version: "1.0"`)
|
|
571
|
+
- [ ] Idempotency implementada en todos los handlers
|
|
572
|
+
- [ ] Dead Letter Queue configurada para cada cola
|
|
573
|
+
- [ ] Retry con backoff exponencial y jitter
|
|
574
|
+
- [ ] Correlation ID propagado en todos los eventos
|
|
575
|
+
- [ ] Transactional Outbox para garantía de publicación
|
|
576
|
+
- [ ] Monitoring de DLQ (alertas cuando hay mensajes ahí)
|
|
577
|
+
- [ ] Tests de integración con broker real (no mocks)
|
|
578
|
+
- [ ] Documentación de todos los tipos de eventos (event catalog)
|
|
579
|
+
- [ ] Schemas backward-compatible (cambios aditivos únicamente)
|
|
580
|
+
- [ ] Consumer group names descriptivos y estables
|