fix(api): a read-side failure waits for its event instead of losing it

The Redis consumer used to leave a failed event pending and go on to the
next ones, retrying it only at the next start. With Mongo down for a
while, a later change of the same ticket was then projected first, and
the version check dropped the earlier one for good (a status change
could vanish from the read model), besides the read side lagging until
a restart.

Now a failed event is retried in place, waiting longer each time (up to
30 s), and the next one waits for it. Failures of the infrastructure
(Mongo, Redis, the network: isTransientError) wait as long as it takes,
so the read side catches up by itself; an event that keeps failing on
its own goes, after 5 attempts, to <stream>:dead with its error, so it
does not hold up the rest.

GET /api/health reports the way from write to read (events waiting in
the outbox, pending to be projected, set aside) and says `degraded`,
still with 200, when something is stuck.

Tried for real: with Mongo stopped, two status changes went in; the
first failed to project with ECONNREFUSED, and once Mongo was back the
ticket's history had both steps, in order, without restarting the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 22:24:19 +02:00
parent d66b1f3ece
commit a32c937765
16 changed files with 492 additions and 20 deletions

View File

@@ -125,12 +125,26 @@ sequenceDiagram
### De escritura a lectura: Redis
- La tabla `events` es la **bandeja de salida (outbox)**: cada evento entra en la misma transacción que la escritura,
con `published_at` vacío. Si Postgres falla, la escritura falla entera y no queda nada a medias.
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_.
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_. Si Redis
falla, los eventos esperan en la tabla y salen al volver.
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
terminan. El grupo de consumidores hace de **bandeja de entrada (inbox)**, y la idempotencia la ponen las
proyecciones (versión, abajo).
- **Si un evento falla, se reintenta en su sitio** (`EventStreamConsumer`), esperando cada vez más (hasta 30 s), y el
siguiente espera: saltárselo aplicaría antes un cambio posterior y la comprobación de versión descartaría después el
anterior para siempre. Si el fallo es de la infraestructura (Mongo, Redis o la red caídos, ver `isTransientError`),
espera lo que haga falta y se pone al día solo al volver, sin reiniciar. Si es del propio evento (un fallo del
código), tras 5 intentos va al stream `task-stream:events:dead` con el error, para no bloquear el resto. Lo que
queda pendiente al parar se reintenta al arrancar (y `XAUTOCLAIM` recupera lo de instancias caídas).
- `GET /api/health` dice en `events` cuántos eventos esperan en la bandeja de salida, cuántos están pendientes de
proyectar y cuántos se apartaron; con alguno de más, `status: degraded` (pero 200: un retraso de la lectura no
reinicia el contenedor). El stream se recorta a `EVENT_STREAM_MAX_LENGTH` (un millón): solo un corte larguísimo
podría recortar algo pendiente, y Postgres siempre permite reconstruir la lectura.
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
### Lectura: MongoDB

View File

@@ -36,7 +36,10 @@ genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las pet
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
dale su alias `task-stream-*` y úsalo en las URLs.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. `GET /api/health` responde 503 si cae una base de datos; con
las tres en pie, `status: degraded` (200) avisa de eventos que no llegan a la lectura (`events.problems`). Los eventos
apartados por fallar una y otra vez se ven con `redis-cli XRANGE task-stream:events:dead - +`; arreglado el fallo, se
reconstruye la lectura (abajo) o se vuelven a publicar.
### Imágenes

View File

@@ -132,3 +132,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
directo y con la hora.
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.
- De escritura a lectura: si un evento no se puede proyectar, se reintenta en su sitio (el siguiente espera) hasta
que Mongo vuelve, sin reiniciar ni perder cambios; los que fallan por sí mismos se apartan a
`task-stream:events:dead`, y `GET /api/health` avisa de eventos atascados o apartados.

Binary file not shown.

Binary file not shown.