Files
_Assistant_Lead_Tech/knowledge/backend/patterns/async.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:02 +02:00

20 KiB

Backend — Patterns : Async

Extrait de la base de connaissance Lead_tech. Voir knowledge/backend/patterns/README.md pour l'index complet.


Pattern : Exécution asynchrone des tâches longues (queue + outbox light)

  • Objectif : sortir les opérations longues ou fragiles du chemin request/response.
  • Contexte : envoi d'emails, appels SaaS, génération de PDF, traitements batch, webhooks sortants.
  • Quand l'utiliser : dès qu'une opération peut dépasser la latence acceptable ou dépendre d'un service externe.
  • Quand l'éviter : opérations réellement instantanées et sans dépendances externes.
  • Avantage :
    • API plus rapide et plus fiable
    • Retries maîtrisés
    • Meilleure résilience aux pannes externes
  • Limites / vigilance :
    • Demande une discipline stricte sur l'idempotence
    • Nécessite une stratégie minimale de dead-letter ou d'alerting
  • Validé le : 25-01-2026
  • Contexte technique : Backend agnostique + DB transactionnelle + worker

Implémentation (exemple minimal)

- API écrit un job ou event en DB dans la transaction métier
- Worker lit les jobs en attente et exécute
- Retries avec backoff + compteur
- Statut FAILED ou dead-letter + alerte
- Idempotence par clé métier ou idempotency key

Checklist

  • Job créé dans une transaction (évite les pertes)
  • Retries et backoff définis
  • Dead-letter ou statut FAILED visible
  • Idempotence garantie
  • Logs corrélés (requestId/traceId)

Pattern : Webhooks sortants robustes et idempotents

  • Objectif : garantir des intégrations fiables avec des systèmes externes.
  • Contexte : notifications, synchronisations, événements métier sortants.
  • Quand l'utiliser : dès qu'un événement doit être transmis à un tiers.
  • Quand l'éviter : intégrations strictement synchrones et internes.
  • Avantage :
    • Tolérance aux pannes réseau
    • Retries maîtrisés
    • Observabilité des échecs
  • Limites / vigilance :
    • Gestion des retries et du volume
    • Nécessite une idempotence côté consommateur
  • Validé le : 25-01-2026
  • Contexte technique : Backend + HTTP + worker/queue

Implémentation (exemple minimal)

- Événement persisté (outbox) en DB
- Envoi asynchrone via worker
- Retries avec backoff
- Signature du payload (HMAC)
- Idempotency key dans le header

Checklist

  • Payload signé et vérifiable
  • Retries + backoff définis
  • Dead-letter ou statut FAILED visible
  • Idempotence documentée
  • Logs corrélés (requestId/traceId)

Pattern : Hooks fire-and-forget après création DB critique

  • Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client.
  • Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache).
  • Quand l'utiliser : hooks rapides (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP.
  • Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss).
  • Avantage :
    • la 201 part dès la création DB (l'AC critique de la route)
    • chaque hook logge ses propres échecs sans bloquer le caller
    • Promise.allSettled détaché → robustesse même si un hook futur ajoute un comportement async
  • Limites / vigilance :
    • dans Next.js 15+, préférer after() (cf. knowledge/backend/patterns/nextjs.md) qui garantit l'exécution post-réponse même en serverless
    • Promise.all reject au premier échec — allSettled attend toutes les promesses
    • tests : poll DB borné (waitForX) plutôt que setTimeout(50) (cf. knowledge/backend/patterns/tests.md)
  • Validé le : 30-04-2026
  • Contexte technique : Node.js — RL799_V2

Implémentation

// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle
const created = await prisma.registration.create({ data });

// Promise.allSettled détaché : ne reject jamais, on capture quand même
// au cas où le service de log lui-même bug
void Promise.allSettled([
  sendAcknowledgmentMail(data.email),
  notifyObservers(created.id),
  invalidateCache(`stats:${data.scope}`),
]).catch((err) => {
  logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) });
});

return jsonResponse(201, { data: created });

Règles d'utilisation

  1. L'AC critique doit être atteint avant : la création DB doit réussir (await) — c'est le seul résultat que le client attend.
  2. Chaque hook doit logger ses propres échecs : le service mail doit avoir son propre logger.error sur status=failed. Le .catch() du Promise.allSettled est un filet, pas le canal d'audit primaire.
  3. Promise.allSettled (pas Promise.all) : robuste si un hook futur ajoute un comportement asynchrone derrière.
  4. Côté tests : helper waitForX polling-borné plutôt que setTimeout(N) arbitraire.

Pattern : Notification fanout fire-and-forget avec filtre grade plancher

  • Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue.
  • Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (SOIREE_CANCELLED à tous les membres, COMMUNICATION_PUBLISHED aux membres de grade ≥ X, etc.).
  • Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire.
  • Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction.
  • Avantage :
    • seuil monotone gradeRank(member) >= gradeRank(resource) aligné sur les filtres list* consommateurs
    • exclusion du créateur via id: { not: userId } pour éviter de se notifier soi-même
    • log explicite sur catch du fire-and-forget — pas de perte silencieuse
  • Limites / vigilance :
    • pas de transaction avec la création principale : best-effort, dégradation acceptable
    • le linkUrl doit être rôle-aware (cf. knowledge/backend/risques/general.md risque-notif-linkurl-non-role-aware)
  • Validé le : 23-04-2026
  • Contexte technique : Prisma — RL799_V2

Implémentation

const createResourceNotifications = async (input: {
  resourceId: string;
  grade: string;           // plancher (seuil monotone)
  excludeUserId?: string;
}): Promise<void> => {
  const thresholdRank = gradeRank(input.grade);

  const recipients = await prisma.user.findMany({
    where: {
      isActive: true,
      role: { in: [...ROLES_ALL_ACTIVE] },
      id: input.excludeUserId ? { not: input.excludeUserId } : undefined,
      profile: { is: {} },
    },
    select: {
      id: true,
      role: true,   // pour linkUrl rôle-aware si multi-rôles
      profile: { select: { grade: true } },
    },
  });

  const eligibleIds = recipients
    .filter((r) => {
      const g = r.profile?.grade;
      if (!g) return false;
      return gradeRank(g) >= thresholdRank;
    })
    .map((r) => r.id);

  if (eligibleIds.length === 0) return;

  await prisma.notification.createMany({
    data: eligibleIds.map((recipientId) => ({
      type: NotificationType.RESOURCE_CREATED,
      recipientId,
      // …
      linkUrl: ..., // rôle-aware si nécessaire
    })),
  });
};

// Côté handler
try {
  const resource = await createResource({ ... });
  logAction(userId, 'resource:create', ...);

  // Fire-and-forget
  void createResourceNotifications({
    resourceId: resource.id,
    ...minimumDataForNotif,
  }).catch((err) => {
    console.error('[resource:create] notification fanout failed:', err);
  });

  return jsonResponse(201, { data: serialize(resource) });
} catch {
  return errorResponse(500, ...);
}

Pourquoi un seuil monotone

gradeRank(member) >= gradeRank(resource) = "à partir du grade X", aligné sur les filtres list* consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter.


Pattern : Auto-purge côté vue via fenêtre temporelle SQL

  • Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents.
  • Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda).
  • Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable.
  • Quand l'éviter :
    • volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose
    • RGPD / obligations légales de suppression — il faut vraiment supprimer la donnée, pas la masquer
    • données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe
  • Avantage :
    • pas de cron à écrire, déployer, monitorer
    • zéro risque de purge destructive : la donnée reste en DB
    • rétention courte est déclarative (paramètre de query), pas cachée dans un job planifié
    • l'admin conserve l'accès complet via un autre endpoint
  • Limites / vigilance :
    • index sur createdAt indispensable dès que la table grossit
  • Validé le : 23-04-2026
  • Contexte technique : Prisma / Postgres — RL799_V2

Implémentation

export const listRecentXxxForMember = async (
  ...filters,
  sinceDays = 30,
) => {
  const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
  return prisma.xxx.findMany({
    where: {
      ...filters,
      createdAt: { gte: since },
    },
    orderBy: { createdAt: 'desc' },
  });
};

L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.


Pattern : Test de rollback pour pipelines multi-fichiers atomiques

  • Objectif : garantir qu'un pipeline qui écrit N fichiers avec rollback nettoie réellement l'état partiel quand le Nème échoue.
  • Contexte : opération qui persiste plusieurs artefacts (ex : variantes/dérivés d'une image, fichiers d'un export) en gardant la liste des chemins déjà écrits pour pouvoir les supprimer en cas d'échec.
  • Quand l'utiliser : tout pipeline « tout ou rien » sur N écritures de fichiers avec compensation manuelle (pas de transaction native).
  • Quand l'éviter : écriture d'un seul fichier, ou stockage qui offre une vraie transactionnalité.
  • Avantage :
    • couvre le cas limite réel (échec en cours de pipeline) plutôt que le seul chemin nominal
    • détecte une compensation incomplète (fichiers orphelins) ou excessive (suppression d'un fichier non écrit par ce pipeline)
  • Limites / vigilance :
    • le test doit être mis à jour quand N change (ajout d'un variant) pour couvrir le nouveau cas limite
  • Validé le : 03-04-2026
  • Contexte technique : Node.js / écriture fichiers — app-template-resto story 4.3

Règle

Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrits si un échec survient) doit avoir un test unitaire couvrant le cas « N-1 fichiers écrits + le Nème échoue ». Ce test vérifie :

  • que les N-1 fichiers déjà écrits sont exactement supprimés (ni plus, ni moins) ;
  • que la phase de finalisation (finalize() ou équivalent) n'est pas appelée ;
  • que l'erreur est bien propagée à l'appelant.

Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.


Pattern : Promise.all des queries DB indépendantes dans un service handler

  • Objectif : réduire la latence d'un service handler dont les queries DB sont posées en série alors qu'elles n'ont aucune dépendance de données.
  • Contexte : service handler API avec plusieurs await chaînés (lectures DB indépendantes).
  • Quand l'utiliser : >2 await dans le corps du handler, dont les arguments ne dérivent pas du résultat précédent.
  • Quand l'éviter :
    • contrôles d'autorisation (auth → user lookup → permission) : garder en série pour fail-fast et ne pas gaspiller de queries sur une requête non autorisée
    • boucles for (...) await à pagination progressive / arrêt anticipé : volontairement séquentielles
  • Avantage :
    • latence totale ~max() au lieu de ~sum()
    • gain proportionnellement plus grand en prod réseau (chaque round-trip cumule en série, s'absorbe en parallèle)
  • Limites / vigilance :
    • les queries conditionnelles doivent être emballées en Promise.resolve(default) pour garder le type Promise<T> dans le tuple
    • la transformation des résultats reste APRÈS le Promise.all ; les dérivations synchrones des seuls paramètres d'entrée peuvent passer AVANT
  • Validé le : 11-05-2026
  • Contexte technique : Next.js 16 API + Prisma 7 — RL799_V2

Anti-pattern (séquentiel)

const odjItems = soireeId ? await findOdjItemsBySoiree(soireeId) : [];
const issues = await findLatestIssuesByTenueIds(tenueIds);
const soireeContext = soireeId ? await getSoireeContext(soireeId) : null;
const convocations = await findConvocationsByTenuesAndGrade(tenueIds, userId);
// total : ~sum(latence_de_chaque_query)

Symptômes : endpoint dans le top des plus lents, curl -w "%{time_total}" corrélé linéairement au nombre d'await, chaque query individuelle rapide (~50-70 ms) sans N+1 — c'est l'orchestration qui est en cause.

Pattern (parallèle)

const [odjItems, issues, soireeContext, convocations] = await Promise.all([
  soireeId ? findOdjItemsBySoiree(soireeId) : Promise.resolve([]),
  findLatestIssuesByTenueIds(tenueIds),
  soireeId ? getSoireeContext(soireeId) : Promise.resolve(null),
  findConvocationsByTenuesAndGrade(tenueIds, userId),
]);
// total : ~max(latence_de_chaque_query)

Détection

  1. Mesurer (curl -w "%{time_total}" / console.time()), cibler les endpoints > 150 ms en local DB chaude.
  2. Compter les await du handler ; >2 hors contrôles d'autorisation = suspect.
  3. Pour chaque await N+1, vérifier si l'argument dérive du résultat de await N. Si NON → parallélisable.

Tests

Le contrat de l'endpoint ne change pas. Les tests qui mockent les repositories passent sans modification. Si un test casse, c'est qu'il sur-spécifiait l'ordre d'exécution (anti-pattern de test).

Cas vécu : dashboardService.handleProchaineTenue — 4 queries séquentielles → Promise.all. 240 ms → 170 ms (-29% local, -40 à -50% projeté prod réseau), 1526/1526 tests verts sans modification.


Pattern : Cron multi-instance idempotent via lock Redis SET NX EX

  • Objectif : garantir qu'un @Cron ne s'exécute qu'une seule fois en environnement multi-pods, sans introduire de scheduler distribué (Bull, etc.).
  • Contexte : @Cron (@nestjs/schedule) déployé sur N instances avec effets de bord (notifications, mails de campagne).
  • Quand l'utiliser : tout cron avec effet de bord non idempotent en déploiement multi-instance.
  • Quand l'éviter : instance unique garantie, ou job purement idempotent.
  • Avantage :
    • aucune dépendance supplémentaire
    • auto-libération du lock si le pod crashe (TTL)
  • Limites / vigilance :
    • TTL > durée du job mais < intervalle entre deux exécutions
    • Redis down → renvoyer false (s'abstenir : ne pas risquer une double exécution d'effets de bord)
    • expliciter l'expression cron ET la timezone — les raccourcis CronExpression.* partent en UTC serveur
  • Validé le : 04-06-2026
  • Contexte technique : NestJS / @nestjs/schedule / Redis — app-alexandrie

Implémentation

// Lock distribué : n'exécuter le corps QUE si SET NX renvoie 'OK'
async acquireLock(key: string, ttlSec: number): Promise<boolean> {
  const res = await client.set(key, '1', { NX: true, EX: ttlSec });
  return res === 'OK';
}

@Cron('0 8 * * 1', { timeZone: 'Europe/Paris' }) // expression + TZ explicites
async runWeeklyJob() {
  if (!(await this.acquireLock('cron:weekly-job', 600))) return; // un autre pod a gagné, ou Redis down
  // ... corps du job ...
}

Règle

Expliciter l'expression cron ET la timezone ({ timeZone: 'Europe/Paris' }) : les raccourcis CronExpression.EVERY_WEEK / EVERY_DAY_AT_9AM s'exécutent en UTC (heure serveur) → décalage vs l'intention métier.


Pattern : Verrou atomique « une action par entité » via updateMany conditionnel

  • Objectif : garantir qu'une action ne peut être déclenchée qu'au plus une fois par entité (relance, remboursement, envoi de mail de campagne, confirmation), résistant aux requêtes concurrentes.
  • Contexte : endpoint qui déclenche un effet de bord one-shot marqué par un champ (sentAt, refundedAt...).
  • Quand l'utiliser : tout flow « au plus une fois par entité » exposé à des appels concurrents.
  • Quand l'éviter : action naturellement idempotente côté effet de bord.
  • Distinction avec le lock Redis cron : ici le verrou est porté par la DB (champ d'état de l'entité, atomicité de l'UPDATE ... WHERE), pas par un lock Redis externe ; il protège une action par entité, pas une exécution unique de job multi-pods.
  • Avantage :
    • guard précoce (fast-path) qui évite le coût des requêtes intermédiaires pour les appels normaux
    • updateMany conditionnel atomique qui tranche la race condition
  • Limites / vigilance :
    • le if (alreadySent) return 409 initial seul ne suffit pas : deux requêtes concurrentes le passent toutes les deux
    • lock.count === 0 = une autre requête a gagné la course → 409
  • Validé le : 20-06-2026
  • Contexte technique : Prisma / Postgres — RL799_V2

Implémentation

// Guard précoce (optimistic fast-path, non atomique — suffit pour 99% des cas)
if (entity.sentAt) return errorResponse(409, 'ALREADY_SENT', '...');

// ... traitement coûteux ...

// Verrou atomique : UPDATE conditionnel WHERE sentAt IS NULL
const lock = await prisma.entity.updateMany({
  where: { id: entityId, sentAt: null },
  data: { sentAt: new Date() },
});
if (lock.count === 0) {
  return errorResponse(409, 'ALREADY_SENT', '...');
}

Pattern : Effet de bord externe avec statut persistant — « persist-then-send »

  • Objectif : ne pas faire mentir un statut métier qui reflète l'état d'un effet de bord externe, en cas d'échec partiel.
  • Contexte : opération qui (1) persiste une donnée nécessaire à une vérification ultérieure (token hash, idempotency key), (2) déclenche un effet externe best-effort (mail, webhook), (3) expose un statut (pending|emailed|active).
  • Quand l'utiliser : dès que le statut exposé doit refléter l'état RÉEL de l'effet externe et que ce dernier peut échouer.
  • Quand l'éviter : pas d'effet externe, ou statut sans valeur métier (rejouabilité non requise).
  • Avantage :
    • tout artefact émis reste traçable côté serveur (donnée vérifiable posée d'abord)
    • le statut reste pending (rejouable) si l'effet externe échoue
  • Limites / vigilance :
    • exige DEUX écritures (poser la donnée vérifiable, puis marquer le statut) — pas un seul update atomique
  • Validé le : 16-06-2026
  • Contexte technique : Prisma / Resend / Keycloak — RL799_V2

Règle

Séparer en deux écritures : d'abord poser la donnée vérifiable SANS toucher au statut, PUIS — seulement si l'effet externe réussit — faire passer le statut. Ordre persist token → send → mark status.

// ✅ token toujours vérifiable même si le mail rate ; statut = état réel (reste pending → rejouable)
setOnboardingToken(...)      // hash + expiresAt, statut INCHANGÉ
await executeActionsEmail()  // effet externe (attendre le 204)
markOnboardingEmailed(...)   // statut passé à 'emailed' APRÈS confirmation

// ❌ un seul update atomique avant l'envoi : le statut ment si Resend/Keycloak est down
update({ status: 'emailed', tokenHash, expiresAt })

Cas vécu : RL799 Lot B onboarding Keycloak — setOnboardingToken (statut inchangé) puis markOnboardingEmailed (statut après 204 confirmé de executeActionsEmail).