Skip to content
acceptedAudienceDevSécuritéOpsOwner@platform-teamDernière revue2026-05-21

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

  1. 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.
  2. 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.
  3. Redis est déjà disponible. L'orchestrator utilise Redis (cache, db 1) ; le service notifications utilise 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é producerjobId = ${transactionId}:${event}. Une publication 2x du même couple est rejetée silencieusement par BullMQ. Couvre le retry idempotent du confirm (FU-2).
  • Latence du confirm bornéeQueue.add(...) est un LPUSH Redis (~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 en failed ; BullMQ conserve les removeOnFail: 500 derniers 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 confirm de notifications.
  • 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 db Redis 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.ts mis à jour : assertion stricte sur l'appel notify(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

Nex — Plateforme fintech CEMAC