ADR-0012 — Bus async pour les notifications de transaction (BullMQ)
Status : Accepted (2026-05-09) Deciders : équipe Nex Story : NEX-438-FU-7 Type : Architecture infrastructure / résilience
Contexte
À l'issue d'un POST /v1/intents/:id/confirm réussi, l'orchestrator déclenche des notifications (push in-app + SMS + email) via le NotificationEngineService. Pré-FU-7, ce dispatch était fait en fire-and-forget en mémoire (handleEvent(...).catch(log)), ce qui :
- ne garantissait aucune persistance — un crash de l'orchestrator entre le commit ledger et le dispatch perdait la notif ;
- n'avait pas de retry si le service
notificationsétait down ; - swallowait silencieusement les erreurs sans observabilité.
Le risque opérationnel le plus visible : pendant un cold start ou un incident de notifications, plusieurs centaines de transactions P2P peuvent passer côté ledger sans qu'aucune notification ne soit envoyée — sans trace pour rattraper a posteriori.
Contraintes
- Le confirm ne doit JAMAIS échouer à cause des notifications. La transaction est déjà commit dans le ledger. Faire échouer le HTTP = user voit "transaction échouée" alors que l'argent est parti = incident compliance.
- Pas de DB côté orchestrator. Le service est un Policy Enforcement Point pure HTTP — il n'a pas de schéma Postgres propre. L'option outbox Postgres local nécessiterait une migration majeure (ajout d'un schéma + ConnectionPool + entités) hors scope FU-7.
- Redis est déjà disponible. L'orchestrator utilise Redis (cache, db 1) ; le service
notificationsutilise Redis + BullMQ pour sa queue interne.
Décision
Bus async basé sur BullMQ Redis dans le process orchestrator.
Architecture :
Producer et consumer cohabitent dans le même process orchestrator. La queue Redis sert de tampon persistant entre les deux. Si le worker crash entre le consume et le complete, BullMQ re-livre le job ; si Redis crash, l'AOF préserve les jobs ; si notifications est down, le worker re-throw et BullMQ applique attempts: 5 avec backoff exponentiel 60s × 2^n (cumul ≈ 31 minutes avant DLQ).
Garanties
- At-least-once — BullMQ persiste sur Redis (AOF), re-livre les jobs abandonnés (visibility timeout), et applique le retry configuré.
- Idempotence côté producer —
jobId = ${transactionId}:${event}. Une publication 2x du même couple est rejetée silencieusement par BullMQ. Couvre le retry idempotent duconfirm(FU-2). - Latence du confirm bornée —
Queue.add(...)est unLPUSHRedis (~1-3 ms). Si Redis est down, l'erreur est swallow + log error et le confirm retourne quand même en succès (le ledger a déjà commit). - DLQ visible — après
attemptsépuisés, le job termine enfailed; BullMQ conserve lesremoveOnFail: 500derniers en Redis pour debug / replay manuel.
Alternatives écartées
A — Outbox Postgres dans l'orchestrator
INSERT INTO orchestrator.notification_outbox dans la même tx que la transition succeeded côté ledger ; un cron worker drainage.
Écartée : l'orchestrator n'a pas de DB propre, ajouter un schéma + ConnectionPool + migrations + entité Doctrine/TypeORM est un chantier majeur. Et le ledger est sur un autre service (couplage tx Postgres impossible sans 2-phase commit).
B — Outbox côté ledger-wallets
INSERT dans la tx ledger atomique avec la transition succeeded. Le ledger publie ensuite sur Redis ou directement sur notifications.
Écartée : couple le ledger (qui doit rester financier pur) à un contrat de notification métier qui appartient à l'orchestrator. Mauvaise séparation des préoccupations.
C — HTTP fire-and-forget actuel + retry au catch
Garder le pattern actuel mais ajouter un buffer in-process avec retry.
Écartée : un crash de l'orchestrator perd le buffer. Pas de persistance.
D — Worker dans un process dédié (microservice)
Extraire le worker dans son propre service NestJS avec déploiement indépendant.
Reportée à V2. Pertinent quand le volume de notifications justifie un scaling horizontal indépendant. En V1 P2P CEMAC, le volume est faible — on garde le worker dans le même process pour limiter les unités déployables.
Conséquences
Positives :
- Découplage robuste du
confirmdenotifications. - Retry/backoff/DLQ standard BullMQ — pas de code custom à maintenir.
- Observabilité native BullMQ (BullBoard, Prometheus exporter).
- Idempotence par
jobId— prête pour les retries idempotents FU-2.
Négatives :
- Couplage à Redis pour les notifications (acceptable : Redis est déjà une dépendance critique du cache).
- Worker BullMQ dans le même process orchestrator — impacte marginalement le CPU/mémoire en cas de pic de notifications, mais en V1 c'est négligeable.
Migration :
- Aucune migration runtime — le code utilise une
dbRedis dédiée (REDIS_QUEUE_DB=2, default), sans collision avec le cache existant. - Variables d'env optionnelles à ajouter en staging/prod (Doppler) :
REDIS_QUEUE_DB=2(default safe).
Validation
- ADR commité avec la story FU-7.
- Specs unitaires :
transaction-events.bus.spec.ts(3 tests : jobId déterministe, fallback aléatoire, swallow si Redis down).transaction-events.worker.spec.ts(3 tests : délégation àhandleEvent, re-throw pour retry, skip job inconnu).confirm-intent.use-case.spec.tsmis à jour : assertion stricte sur l'appelnotify(event, ctx, correlationId)à 3 arguments.
- Aucune régression introduite (les 2 suites pré-cassées le sont déjà sur la baseline pré-FU-7).
Références
- Story :
_bmad-output/implementation-artifacts/NEX-438-FU-7-notification-dispatch-async.md - Code :
services/orchestrator/src/infrastructure/queue/transaction-events/ - BullMQ docs : https://docs.bullmq.io/
- Pattern outbox : Kleppmann, Designing Data-Intensive Applications ch. 11