Files et workers
Le backend sort de la requête HTTP les traitements lents ou à risque : envoi des emails, actions on-chain différées (frappe des POAP, versement de la cagnotte, remboursements) et lecture continue de la blockchain. Les deux premiers passent par des files BullMQ dans Redis ; la lecture de la chaîne et le keeper sont des boucles à intervalle fixe. Tous ces traitements peuvent tourner dans le process de l'API ou dans un process worker séparé. Le principe du repli sans Redis est justifié dans l'ADR 0002.
Vue d'ensemble
| Traitement | Type | Producteur | Consommateur | Concurrence |
|---|---|---|---|---|
emails-queue |
file BullMQ | enqueueEmail (auth, boutique, RGPD, support) |
backend/src/workers/email.worker.ts |
WORKER_CONCURRENCY (5) |
ledger-queue |
file BullMQ | enqueueLedgerTransaction, jamais appelée |
backend/src/workers/ledger.worker.ts |
1 |
chain-actions |
file BullMQ | enqueueChainAction (indexeur) |
startChainActionsWorker de backend/src/blockchain/chain-actions.ts |
1 |
| Indexeur | boucle setInterval |
aucun | lotteryIndexer.syncOnce |
1, garde syncing |
| Keeper | boucle setInterval |
aucun | runKeeperOnce de backend/src/blockchain/lottery.keeper.ts |
1, verrou Redis |
backend/src/workers/index.ts regroupe les trois workers BullMQ (startWorkers, closeWorkers). L'indexeur et le keeper sont démarrés par startChainServices, seulement en mode onchain.
Options communes des jobs
backend/src/queues/queue.options.ts définit les options appliquées par défaut aux trois files :
| Option | Valeur | Effet |
|---|---|---|
attempts |
5 | un job en échec est retenté jusqu'à 5 exécutions |
backoff |
exponentiel, 1 000 ms, gigue 0,5 | délais d'environ 1, 2, 4 puis 8 secondes, avec une variation aléatoire |
removeOnComplete |
24 heures ou 1 000 jobs | les jobs terminés sont purgés au-delà |
removeOnFail |
14 jours | les jobs définitivement échoués restent consultables deux semaines |
Un job qui lève UnrecoverableError n'est pas retenté. Chaque fin de job incrémente queue_jobs_total{queue, status} avec completed ou failed.
Les files utilisent le client ioredis partagé de backend/src/config/redis.config.ts, avec le préfixe de clés par défaut de BullMQ : les clés sont bull:emails-queue:*, bull:ledger-queue:* et bull:chain-actions:*, sans REDIS_KEY_PREFIX. Deux environnements qui partagent la même base Redis partagent donc leurs files.
File emails-queue
await enqueueEmail({
to: user.email,
subject: 'Votre code de connexion - BlockLucky',
templateName: 'otp-code',
variables: { CODE: code, EXPIRES_MINUTES: '10' },
});Le producteur ajoute un job send-email et renvoie { queued: true, jobId }. Si la file n'existe pas ou si l'ajout échoue, l'email est envoyé immédiatement dans la requête et la fonction renvoie { queued: false }. Le worker vérifie que to et templateName sont présents (sinon UnrecoverableError) puis appelle emailService.sendTemplateEmail.
Le job accepte un champ facultatif replyTo, transmis à Nodemailer comme adresse de réponse. Seul le message au support (POST /user/support) le renseigne, avec l'email du citoyen.
Attention
sendTemplateEmail ne lève pas d'erreur quand le serveur SMTP refuse le message : elle journalise un avertissement et renvoie false. Le job est alors marqué terminé, sans nouvelle tentative. Les 5 tentatives ne couvrent en pratique que les erreurs inattendues. Un code de connexion peut donc être perdu sans trace autre qu'une ligne warn contenant « Mode simulation d'envoi d.email ».
File ledger-queue
enqueueLedgerTransaction({ userId, amount, actionType, description, operation }) met en file un crédit ou un débit d'EtherPoints, exécuté un par un par le worker (concurrence 1) pour sérialiser les écritures. Une AppError de statut inférieur à 500 (solde insuffisant, compte introuvable) devient une UnrecoverableError.
À savoir
Aucun code n'appelle enqueueLedgerTransaction : tous les mouvements de points sont écrits directement par ledgerService pendant la requête ou pendant l'indexation. Le worker ledger démarre et attend des jobs qui n'arrivent jamais. La file peut être supprimée, ou branchée sur les crédits de l'indexeur si leur volume l'exige.
File chain-actions
Les actions on-chain qui découlent d'un événement du contrat sont mises en file par l'indexeur, pour ne pas bloquer l'indexation et pour bénéficier des nouvelles tentatives. Le détail fonctionnel est sur la page File d'actions on-chain.
| Job | Mis en file à | Signé par | Appel |
|---|---|---|---|
MINT_BADGE |
chaque TicketsPurchased nouvellement indexé, pour le joueur |
compte opérateur | BlockLuckyPOAP.mintBadge(joueur, tour) |
CLAIM_PRIZE |
WinnerPicked, pour le gagnant |
portefeuille intégré du gagnant | BlockLucky.claimPrize(tour) |
CLAIM_REFUND |
RoundCancelled, pour chaque payeur distinct |
portefeuille intégré du payeur | BlockLucky.claimRefund(tour) |
| Règle | Détail |
|---|---|
| Déduplication | jobId = <type>-<tour>-<adresse> : un second ajout identique est ignoré par BullMQ tant que le job existe, terminé ou échoué |
| Déjà fait | PrizeAlreadyClaimed, NothingToRefund, BadgeAlreadyMinted : le job se termine sans erreur |
| Erreur définitive | NotWinner, RoundNotCompleted, RoundNotCancelled, ZeroAddressNotAllowed : UnrecoverableError, pas de nouvelle tentative |
| Compte sans clé | portefeuille inconnu ou sans walletEncryptedKey : le job se termine sans rien faire |
| Concurrence | 1, pour éviter les conflits de nonce du compte opérateur |
| Mode simulé | enqueueChainAction ne fait rien et le worker n'est pas créé |
Comme le jobId est conservé 14 jours après un échec définitif, il faut supprimer le job échoué pour pouvoir remettre la même action en file. Voir Runbooks blockchain.
Indexeur et keeper
| Boucle | Période | Rôle |
|---|---|---|
lotteryIndexer.syncOnce |
INDEXER_INTERVAL_MS (3 000 ms) |
lit au plus 2 000 blocs de journaux du contrat depuis le curseur ChainCursor, projette les événements en base, avance le curseur |
runKeeperOnce |
KEEPER_INTERVAL_MS (10 000 ms) |
si le contrat n'est pas en pause : requestDraw() quand isDrawReady(), sinon closeExpiredRound() quand isExpiredWithoutDraw() |
L'indexeur fait un premier passage dès le démarrage. Un passage en cours bloque le suivant dans le même process. Le keeper prend le verrou Redis chain:keeper-lock (durée de KEEPER_INTERVAL_MS, 5 secondes au moins) et le relâche à la fin de chaque passage : deux process ne demandent pas le tirage en même temps. Le fonctionnement détaillé est dans Indexeur d'événements et Keeper du tirage.
Où tournent les workers
| Configuration | Process API | Process worker |
|---|---|---|
Développement par défaut (RUN_WORKERS=true, npm run dev) |
API, Socket.IO, 3 workers, indexeur, keeper | absent |
Séparé (RUN_WORKERS=false + npm run dev:worker ou npm run start:worker) |
API, Socket.IO, relais Redis | 3 workers, indexeur, keeper |
| Stack Docker | service backend, RUN_WORKERS=false |
service worker, commande node dist/src/worker.js |
Le process worker ignore RUN_WORKERS : il démarre toujours ses workers et, en mode onchain, l'indexeur et le keeper. Les événements temps réel qu'il produit sont relayés vers l'API par Redis (voir Relais depuis le worker).
Sondes du process worker
Le worker ouvre un serveur HTTP minimal sur PORT + 1 (2054 avec la configuration par défaut) :
| Chemin | Réponse |
|---|---|
/health/live |
200 { "status": "ok" }, ou 503 { "status": "shutting_down" } pendant l'arrêt |
/metrics |
métriques Prometheus du worker si METRICS_ENABLED=true ; aucun contrôle de METRICS_TOKEN |
| autre | 404 sans corps |
Le service Docker worker utilise /health/live sur le port 2054 comme sonde de santé ; ce port n'est pas publié sur l'hôte. Si startWorkers ne crée aucun worker, le process journalise Aucun worker démarré : Redis est requis pour le process worker et s'arrête avec le code 1.
Fonctionnement sans Redis
| Situation | Comportement |
|---|---|
NODE_ENV=test |
aucun client Redis n'est créé : aucune file, enqueueEmail appelle directement sendTemplateEmail (qui n'envoie rien en test), aucun worker |
| Redis injoignable | l'ajout en file échoue : enqueueEmail envoie l'email pendant la requête et enqueueChainAction lance l'action sans l'attendre, sans nouvelle tentative ; le verrou du keeper, l'idempotence et les sessions révoquées passent dans la mémoire du process |
Le client ioredis abandonne la reconnexion après trois tentatives espacées de 200 à 600 ms (retryStrategy renvoie null à la quatrième). Une fois abandonnée, ioredis ne rétablit plus la connexion de lui-même : si Redis redevient disponible, le process reste en repli mémoire jusqu'à son redémarrage, et /health/deep signale redis: down. Après un incident Redis, redémarrez l'API et le worker.
Danger
En repli, les jobs ne sont pas persistés : une action on-chain lancée directement et interrompue par un arrêt du process est perdue. Les remboursements et versements peuvent alors être relancés manuellement par POST /lottery/claim-prize ou depuis le contrat.
Arrêt
À l'arrêt, closeWorkers attend la fin des jobs en cours de chaque worker (worker.close()), puis les files sont fermées avec les autres ressources (voir Arrêt propre). Un job interrompu par l'arrêt forcé est repris par BullMQ au redémarrage suivant, comme un job bloqué.