fix(api): retry commands that clash with a concurrent write

Services run commands through a CommandRunner that runs one again (up to
5 times, waiting a little longer each time) when its aggregate changed
underneath, e.g. a saga writing to the same ticket. Each command loads its
aggregate fresh and stores its events in one transaction, so a
ConcurrencyError means nothing was stored and running it again is safe.
People no longer get a 409 for it, and the e2e suite stops flaking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:00:08 +02:00
parent 76312c079d
commit e14f445e6b
23 changed files with 208 additions and 114 deletions

View File

@@ -30,7 +30,8 @@ Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries`
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por
- Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que
los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
@@ -109,7 +110,10 @@ sequenceDiagram
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
segunda falla con `ConcurrencyError`. El `CommandRunner` (`shared/application`) la repite sobre el estado nuevo hasta
5 veces, con una pequeña espera creciente: cada comando carga su agregado de cero y guarda sus eventos en una sola
transacción, así que un choque significa que no se guardó nada. Solo si sigue chocando llega al cliente (código
GraphQL `CONFLICT`, HTTP `409`). Las sagas tienen su propio reintento en el `EventsBridge`.
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una