BlockLucky Documentation officielle GitHub

0002 Redis pour les files et l'état court, avec repli en mémoire

  • Statut : accepté
  • Date : 2026-11-10

Contexte

Le backend utilise Redis pour les files BullMQ (emails, ledger), le rate limiting et quelques états courts (préférences, quêtes, disjoncteur blockchain). L'environnement de développement doit fonctionner même sans Redis.

Décision

  • Une seule instance Redis en local, configurée en maxmemory-policy noeviction (obligatoire pour BullMQ) avec persistance AOF everysec.
  • Toutes les clés applicatives sont préfixées blocklucky:<environnement>: et écrites avec un timeout de commande court (500 ms).
  • Si Redis est indisponible : les lectures et écritures basculent sur une mémoire locale, les jobs sont exécutés directement dans la requête et le rate limiter laisse passer (fail-open) au lieu de bloquer l'API.
  • Les jobs BullMQ ont 5 tentatives avec backoff exponentiel et jitter. Les erreurs métier (4xx) sont marquées UnrecoverableError pour ne pas être rejouées. Les jobs terminés sont conservés 24 h, les échecs 14 jours.

Conséquences

  • L'API reste disponible si Redis tombe, au prix d'un rate limit non partagé et d'une exécution synchrone des emails.
  • En production, il faudra séparer une instance cache (éviction autorisée) d'une instance files et état (noeviction, réplica et failover). Ce n'est pas fait car aucun environnement hébergé n'existe encore.

Vérification dans le code

Cette section confronte la décision au dépôt actuel. Elle ne modifie pas la décision.

Ce qui est conforme

Élément de la décision Constat Où
Instance locale redis:7.4-alpine lancé avec --appendonly yes, --appendfsync everysec, --maxmemory 256mb, --maxmemory-policy noeviction docker-compose.yml
Préfixe et timeout RedisManager préfixe chaque clé par ${REDIS_KEY_PREFIX}:${NODE_ENV}: (blocklucky par défaut) et coupe chaque commande à 500 ms (CACHE_COMMAND_TIMEOUT_MS) backend/src/config/redis.config.ts
Repli en mémoire get, set, setIfAbsent et del utilisent une Map locale si le client n'est pas prêt ou si la commande échoue backend/src/config/redis.config.ts
Exécution directe des emails enqueueEmail appelle emailService.sendTemplateEmail si l'ajout à la file échoue backend/src/queues/emails.queue.ts
Rate limit en fail-open passOnStoreError: true sur les deux limiteurs backend/src/middlewares/rateLimiter.middleware.ts
Options des jobs attempts: 5, backoff: { type: 'exponential', delay: 1000, jitter: 0.5 }, removeOnComplete: { age: 24 h, count: 1000 }, removeOnFail: { age: 14 jours } backend/src/queues/queue.options.ts
Erreurs non rejouées Le worker ledger convertit les AppError de statut inférieur à 500 en UnrecoverableError ; le worker email rejette sans nouvelle tentative un job incomplet ; la file chain-actions fait de même pour les erreurs définitives du contrat backend/src/workers/ledger.worker.ts, backend/src/workers/email.worker.ts, backend/src/blockchain/chain-actions.ts
Instance unique Toujours une seule instance Redis, pas de séparation cache et files docker-compose.yml

Écarts et précisions

Les échecs SMTP ne sont jamais rejoués. sendTemplateEmail (backend/src/services/email.service.ts) attrape l'erreur de transporter.sendMail, la journalise en avertissement et renvoie false. Le job BullMQ se termine donc avec succès : les 5 tentatives prévues ne s'appliquent pas à une panne du serveur SMTP. Un code de connexion peut ainsi être perdu sans nouvelle tentative.

Tous les préfixes ne passent pas par RedisManager. Les files BullMQ utilisent le client brut et le préfixe par défaut de BullMQ (bull:<nom de la file>:). Le rate limit utilise son propre préfixe blocklucky:<environnement>:ratelimit:<nom>: sans le timeout de 500 ms. Le canal du relais Socket.IO est blocklucky:<environnement>:socket-events (backend/src/socket/event-bridge.ts).

Le périmètre de Redis s'est élargi. Redis porte aussi la file chain-actions, les verrous du keeper (chain:keeper-lock) et de la dotation de bienvenue (wallet:grant-lock:<id>), les défis OTP, la révocation des sessions, les réponses idempotentes et le relais pub/sub des événements temps réel. Ces usages sont détaillés dans Données Redis.

Le disjoncteur n'est dans Redis qu'en mode simulé. En mode onchain, l'état de pause est lu dans le contrat (paused()). La clé blockchain:circuit_breaker n'est écrite que par la branche simulée de backend/src/services/blockchain.service.ts.

Des données durables sont stockées comme de l'état court. Les préférences (user:prefs:<id>), les quêtes accomplies (user:quests:<id>), les lots de codes partenaires (partner:codes:<rewardId>) et les dates d'expiration d'offres (partner:offer_expiry:<offerId>) sont écrits sans expiration (backend/src/api/user/user.services.ts, backend/src/api/partners/partners.services.ts). Ils ne sont pas en base : en mode repli mémoire, ils sont perdus au redémarrage du process, et une perte de Redis efface par exemple les préférences d'un citoyen ou les codes promo importés par un partenaire.

Le repli des jobs n'est pas toujours synchrone. Pour chain-actions, enqueueChainAction lance executeChainAction en arrière-plan sans l'attendre si la file est indisponible. La requête HTTP n'attend donc pas la frappe du badge ou le retrait de la cagnotte.

Le worker ne démarre pas sans client Redis. src/worker.ts s'arrête avec le code 1 si aucun worker n'a été créé. En pratique, le client n'est absent qu'en environnement de test (NODE_ENV=test), car RedisManager crée toujours le client ailleurs.

Point d'attention au démarrage. Le client ioredis est créé avec lazyConnect: true : la connexion ne s'ouvre qu'à la première commande. Tant qu'elle n'est pas prête (status !== 'ready'), RedisManager sert les lectures et écritures depuis la mémoire locale. Une donnée écrite pendant ce court intervalle, par exemple un défi OTP, n'est pas visible des autres process. Ce comportement est déduit du code et n'a pas été reproduit.

Pour aller plus loin