Racoonity

La ciudad ya sabe reaccionar

Racoonity cerró su octava iteración: los eventos ya no solo se guardan, ahora pueden activar extensiones desacopladas, recuperables e idempotentes.

Por El equipo de Racoonity •
7 min de lectura
  • #racoonity
  • #arquitectura
  • #desarrollo
  • #eventos

La ciudad ya sabe reaccionar

Hasta ahora, muchas de las piezas importantes de Racoonity ya podían existir por sí mismas.

El catálogo podía definir mercancía. El inventario podía saber dónde estaba. La tienda podía abrir, vender y después consultar qué había ocurrido. Y con la iteración anterior, el sistema empezó a adquirir algo todavía más importante: la capacidad de crecer sin convertir cada nueva idea en una modificación del núcleo.

Pero quedaba una pregunta bastante seria:

¿Qué pasa cuando algo ocurre dentro de Racoonity y otra parte del sistema necesita reaccionar sin que ambas queden pegadas para siempre?

La respuesta de esta iteración fue construir una primera ruta real para eso.

Una venta termina. Algo más puede comenzar.

Imaginemos una venta confirmada.

Para el módulo de ventas, su trabajo debería terminar ahí: confirmar la operación correctamente, persistirla y dejar constancia de que ocurrió.

No debería necesitar saber si después queremos:

  • registrar una observación;
  • alimentar una integración;
  • disparar una notificación;
  • actualizar una estadística;
  • iniciar un proceso externo;
  • o conectar algún motor que todavía ni siquiera existe.

Si cada una de esas posibilidades terminara convertida en una llamada directa desde Sales, tarde o temprano tendríamos un módulo central que conoce a medio sistema.

Eso es exactamente lo que queríamos evitar.

En I8, una venta confirmada deja un evento durable. Después del commit, otro componente puede recogerlo y entregarlo a quien corresponda.

Sales no conoce al consumidor.

El consumidor no participa en la transacción de la venta.

Y el evento permanece en PostgreSQL aunque el proceso desaparezca.

Ese detalle cambia bastante las cosas.

Nace Courier

El nuevo componente de esta iteración se llama Courier.

Su responsabilidad es deliberadamente aburrida: encontrar eventos pendientes, reclamarlos, entregarlos al consumidor registrado y registrar el resultado.

No interpreta ventas.

No sabe de inventario.

No decide qué significa un evento.

No contiene lógica de negocio.

Es, literalmente, el cartero.

Y como cualquier servicio postal digno de confianza, tiene que sobrevivir a situaciones bastante menos bonitas que el camino feliz.

El problema incómodo: “¿y si nos morimos justo aquí?”

Una de las pruebas principales de esta iteración fue provocar una situación específica.

  1. Courier recoge un evento.
  2. El consumidor lo procesa.
  3. El consumidor guarda correctamente su resultado.
  4. Antes de que Courier pueda marcar el evento como publicado, el proceso “muere”.

Ahora tenemos una situación interesante: el trabajo sí ocurrió, pero el sistema no alcanzó a registrar que la entrega terminó.

Cuando el proceso vuelve, Racoonity recupera ese evento y lo entrega otra vez.

Eso significa que la arquitectura no promete una fantasía de “exactamente una vez”.

Promete algo más realista:

al menos una vez, con consumidores idempotentes.

En nuestra prueba, la segunda entrega conserva el mismo identificador de evento. El consumidor detecta que ya lo procesó y no duplica el resultado.

Una entrega repetida deja una sola evidencia.

Ese comportamiento no está simulado con memoria ni depende de que el proceso anterior siga vivo. Todo se reconstruye desde PostgreSQL.

También aprendió a fallar

No todos los eventos terminan bien.

Courier ahora distingue entre un fallo temporal y uno que ya agotó sus oportunidades.

Los intentos se registran de forma durable y los reintentos esperan antes de volver a ejecutarse. Con la configuración actual, el retraso crece linealmente con el número de intento.

Si el consumidor continúa fallando, después del quinto intento el evento queda marcado como fallido.

Y algo importante: la venta original sigue confirmada.

El fallo de una extensión posterior no viaja hacia atrás en el tiempo para deshacer una operación que ya fue correctamente comprometida.

Ese aislamiento era uno de los objetivos centrales de esta iteración.

Sobrevivir a un reinicio

También queríamos comprobar algo que parece obvio hasta que deja de serlo:

reiniciar la aplicación no debe borrar el trabajo pendiente.

La prueba construye un runtime, genera un evento durable y lo destruye sin ejecutar Courier.

Después construye un runtime completamente nuevo sobre la misma base de datos.

No comparte registros en memoria.

No comparte consumidores anteriores.

No comparte objetos de Courier.

El nuevo proceso encuentra el evento pendiente, lo entrega y termina correctamente.

La fuente de verdad sigue siendo PostgreSQL.

El productor sigue sin saber quién escucha

Hay una regla que queríamos proteger incluso con pruebas arquitectónicas:

Sales no puede importar Courier ni conocer al Observer.

El productor únicamente publica su evento.

El consumidor se registra del otro lado.

También dejamos protegidas otras fronteras:

  • el contrato del evento vive en una capa compartida;
  • la escritura al outbox sigue siendo únicamente publicación;
  • Courier posee la maquinaria de entrega;
  • el Observer consume contratos públicos, no repositorios internos de Sales;
  • la infraestructura genérica de PostgreSQL no absorbió la lógica de Courier.

Esto importa porque el objetivo de la iteración no era solamente “hacer funcionar eventos”.

Era hacerlo sin crear otra bola de dependencias.

Un Observer deliberadamente pequeño

Para demostrar el modelo añadimos un consumidor muy sencillo: Demo Sales Observer.

Escucha sale.confirmed y guarda una evidencia mínima de que recibió el evento.

No pretende ser una funcionalidad final de producto.

Es una prueba arquitectónica.

Si mañana queremos conectar otro motor, la idea es que pueda implementar el contrato de consumidor, registrarse y mantener su propio estado sin obligar al productor a conocerlo.

En esta primera versión existe una limitación intencional: hay un solo consumidor por tipo de evento.

Fan-out, brokers externos, webhooks, múltiples procesos y otras posibilidades pertenecen al futuro.

Primero queríamos demostrar la base.

Y sí, intentamos romperlo

La iteración terminó con una matriz bastante agresiva de pruebas.

Probamos, entre otras cosas:

  • eventos todavía no disponibles;
  • eventos sin consumidor registrado;
  • eventos ya publicados;
  • eventos fallidos;
  • locks antiguos y recientes;
  • reintentos;
  • payloads inválidos;
  • versiones de evento no soportadas;
  • entregas repetidas;
  • rollback del productor después de insertar en el outbox;
  • fallos permanentes del consumidor;
  • reinicios del proceso;
  • cancelación durante una entrega;
  • recuperación después de una caída;
  • concurrencia real con FOR UPDATE SKIP LOCKED.

Todo se ejecutó contra PostgreSQL real.

Al final también corrimos el detector de data races de Go sobre Courier, Observer y la composición del runtime.

Cero data races.

La suite completa quedó verde.

Una pequeña pelea con OpenSpec

La implementación estaba terminada, las pruebas pasaban y el cierre técnico estaba aprobado.

Naturalmente, ahí fue cuando apareció el verdadero jefe final: el archivado de documentación.

Dos requirements MODIFIED no coincidían exactamente con los nombres históricos de las specs canónicas. Después descubrimos una segunda regla todavía más importante: OpenSpec interpreta un requirement modificado como su definición completa, por lo que también debía conservar explícitamente los scenarios históricos que queríamos mantener.

Nada estaba mal en producción.

Nada fallaba en runtime.

Pero el archivo no podía cerrarse correctamente hasta que la documentación expresara con precisión la evolución de esos contratos.

Se corrigieron los encabezados, se restauraron los scenarios históricos y finalmente el cambio pudo archivarse sin duplicar requirements ni perder comportamiento documentado.

La validación final dejó 30 specs canónicas válidas y ningún cambio activo.

Una lección útil para las siguientes iteraciones: cuando modifiquemos un requirement existente, partiremos de su definición canónica completa y editaremos desde ahí.

Mucho menos drama burocrático para el mapache.

Qué tenemos ahora

I8 no añade una pantalla nueva.

No añade una opción visible en el menú.

No añade una feature que alguien pueda enseñar en una captura bonita.

Pero cambia profundamente lo que Racoonity puede convertirse después.

Ahora existe una forma concreta de decir:

“esto ocurrió”

sin tener que decir inmediatamente:

“y ahora llama directamente a estas otras cinco cosas”.

Tenemos una ruta durable, desacoplada, reintentable, recuperable e idempotente para extender comportamiento después de un evento.

Todavía es pequeña.

Todavía tiene límites deliberados.

Pero ya funciona.

Y eso significa que la ciudad no solo puede crecer.

Ahora también sabe reaccionar.


Estado de la iteración: I8 CLOSED — ARCHIVED AND VERIFIED