Conception de files d'attente de tâches d'arrière-plan
Un système de tâches d'arrière-plan présente trois modes de défaillance contre lesquels vous devez vous prémunir dès le premier jour : tâches perdues, exécution dupliquée et renvois incontrôlés. Choisissez la mauvaise primitive et vous passerez l'année suivante à corriger ces trois problèmes. Le consensus récurrent de l'industrie, à travers les documentations de BullMQ, Sidekiq, SQS et Temporal, est que l'exactement-une-fois n'est pas une sémantique de livraison honnête — l'au-moins-une-fois est la seule sémantique réaliste. Concevez chaque handler pour être idempotent et arrêtez de courir après l'exactement-une-fois. (Caduh — Queues 101)
Allez directement au problème :
- Les tâches s'exécutent deux fois lors d'un renvoi → Idempotence
- Un worker plante en cours de tâche, la tâche disparaît → Récupération fiable / timeout de visibilité
- La DLQ se remplit, des alertes se déclenchent → Classification des erreurs + DLQ
- Redis OOM pendant un arriéré → Hygiène mémoire
- "Est-ce que ce devrait être Temporal plutôt ?" → File d'attente vs workflow
- Les tempêtes de renvois impactent le système aval → Backoff + jitter
- La concurrence dépasse une limite de débit → Concurrence + limiteur
Quand utiliser
- Lors de la conception d'un nouveau système de tâches d'arrière-plan en Node, Ruby, Python ou Go.
- Lors de la migration de
setTimeout/cron/ files d'attente maison vers un courtier fiable. - Les plantages de worker entraînent la perte de tâches.
- Les renvois en production provoquent des effets secondaires dupliqués.
- Lors du choix entre BullMQ (Node + Redis), Sidekiq (Ruby + Redis), RQ / Celery / Dramatiq (Python), Asynq (Go + Redis), SQS (géré) ou Temporal (exécution durable).
Capacités de base
Choisir la primitive adaptée au travail
| Vous avez | Utilisez | Raison |
|---|---|---|
| Tâches courtes avec effets secondaires (envoyer un email, redimensionner une image) sur Node | BullMQ | Natif Redis, DLQ mature, patterns de flux, ~5k tâches/s/noeud typique |
| Même chose sur Ruby | Sidekiq (Pro pour la fiabilité) | Même forme ; super_fetch Pro ajoute une récupération fiable via LMOVE (Wiki Sidekiq) |
| Workflows multi-étapes avec fan-out vers API externes, durées d'heures à jours | Temporal | Exécution durable ; rejoue à partir de l'historique des événements (Docs Temporal) |
| Natif AWS, fanout simple, ne veut pas gérer Redis | SQS + Lambda | Au-moins-une-fois géré ; timeout de visibilité + DLQ de première classe (AWS SQS DLQ) |
| Isolation multi-région et multi-tenant | Temporal ou SQS | Un seul Redis ne survivra pas proprement en multi-région |
| Besoin d'exactement-une-fois entre DB + email + paiements | Aucun — concevez pour au-moins-une-fois + handlers idempotents | "Exactement-une-fois" à l'intérieur du courtier ne s'étend pas entre les systèmes (Caduh) |
Le critère caché : quelle est la complexité de l'histoire de récupération ? Une simple file d'attente BullMQ avec renvois est suffisante pour "envoyer un reçu." Un workflow d'achat en 12 étapes qui attend 24 heures un webhook est un workflow Temporal, pas 12 tâches de file d'attente chaînées.
Idempotence
La propriété obligatoire. La documentation de chaque file d'attente populaire commence par cela. De la page BullMQ sur les tâches idempotentes : "cela ne devrait pas faire de différence pour l'état final du système qu'une tâche se termine avec succès à sa première tentative, ou qu'elle échoue initialement et réussisse lors d'un renvoi." (BullMQ idempotent-jobs)
Patterns pratiques :
// 1. Clé d'idempotence dérivée de l'événement métier, pas de la file d'attente.
// Utilisez l'event_id amont si vous en avez un (Stripe event.id, id du hook Shopify).
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ déduplique par jobId dans la file d'attente.
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. Dans le handler, dédupliquez également au niveau du site d'effet secondaire.
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// Si déjà envoyé, ne rien faire.
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
Le jobId déduplique les ajouts tant qu'un doublon est encore dans la file d'attente. La contrainte unique en base de données déduplique en cas de plantage de worker, renvois et rejeux. Les deux sont nécessaires ; jobId seul est insuffisant car BullMQ supprime les tâches terminées et la fenêtre de déduplication disparaît.
Récupération fiable et timeout de visibilité
Lorsqu'un worker récupère une tâche et plante, que se passe-t-il ?
| Système | Comportement par défaut | Récupération |
|---|---|---|
| BullMQ | La tâche a un lockDuration (défaut 30s). Le vérificateur de tâches bloquées remet en file d'attente après expiration (BullMQ production) | Les workers DOIVENT prolonger le verrou ou terminer dans le lockDuration ; sinon elle s'exécute deux fois |
| Sidekiq OSS | Le pop n'est pas atomique ; si le worker plante entre le pop et le process, la tâche est perdue | Passez à Sidekiq Pro super_fetch (Redis 6.2+ LMOVE vers une file de travail privée) |
| Sidekiq Pro super_fetch | LMOVE vers une liste de travail par processus. Le heartbeat expire à 60s ; une vérification des orphelins balaie et ré-ajoute (Wiki Sidekiq) | Intégré |
| SQS | Timeout de visibilité (défaut 30s, max 12h). Le message réapparaît après timeout si non supprimé | Définissez le timeout > p99 du temps de handler ; prolongez avec ChangeMessageVisibility pour les longues tâches |
| Temporal | Les activités ont des politiques de heartbeat + renvois. L'historique du workflow survit à la mort du worker | Rejeu à partir de l'historique ; vous n'y pensez pas |
Le piège : lockDuration / timeout de visibilité plus court que le p99 du temps de handler → la tâche s'exécute deux fois. Plus long que le temps de récupération acceptable → le travail planté attend trop longtemps. Mesurez d'abord votre p99, définissez le timeout à ~3x celui-ci, et faites en sorte que le handler effectue un heartbeat / prolonge le verrou pour les tâches vraiment longues.
Classification des erreurs et DLQ
Toutes les erreurs ne doivent pas être renvoyées indéfiniment. D'après la documentation SQS : "Définissez le nombre maximum de réceptions... et la politique de redrive pour envoyer les messages vers la DLQ une fois le seuil dépassé." (AWS SQS DLQ)
// Classez avant de lancer une exception. Les erreurs permanentes ne doivent PAS être renvoyées.
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → DLQ dès la première tentative
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// Déplacer immédiatement vers la DLQ — les renvois n'aideront pas.
job.discard(); // BullMQ : empêche le renvoi
throw e;
}
throw e; // Réseau/timeout → renvoi selon la politique de tentatives
}
}
La DLQ nécessite ensuite :
- Une alerte sur le nombre croissant (pas seulement non nul — "la DLQ a 3 éléments pour toujours" est acceptable ; "la DLQ a augmenté de 100/min" est un incendie).
- Un outil de rejeu (CLI ou interface d'administration) pour qu'un humain puisse corriger le bug amont, rejouer les tâches dead-letter et vider la DLQ.
- Des tableaux de bord (voir
grafana-dashboard-builder).
Backoff et jitter
Le backoff exponentiel seul ne suffit pas. Si 1000 tâches échouent toutes à 12:00:00 parce qu'un service aval a eu un défaut, le backoff exponentiel simple fera que les 1000 renvois auront lieu exactement à 12:00:01, 12:00:03, 12:00:07 — un troupeau piétinant qui pourrait maintenir le service aval hors service. Ajoutez du jitter complet :
// "Full jitter" style AWS : délai = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
Pour la base de référence recommandée par BullMQ, le document going-to-production suggère des intervalles de renvoi entre 1s et 20s avec retryStrategy. (BullMQ production)
Concurrence et limiteur
La concurrence par worker exécute N tâches en parallèle à partir d'un seul processus. Plusieurs workers multiplient cela. Sans limiteur au niveau de la file d'attente, vous ne pouvez pas plafonner les appels agrégés vers un système aval :
// BullMQ — limite de débit globale sur tous les workers de cette file d'attente.
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 100 emails/s sur toute la flotte
});
La bonne forme : plusieurs workers concurrents + un limiteur de file d'attente pour les limites contractuelles aval. Sidekiq a des limiteurs de débit équivalents ; SQS utilise la concurrence Lambda ou le taux par message.
Hygiène mémoire
L'écueil de production BullMQ le plus souligné : maxmemory-policy noeviction sur l'instance Redis. Toute autre politique (allkeys-lru, volatile-lru) supprimera silencieusement les tâches en file d'attente sous pression. (BullMQ production)
Aussi :
removeOnCompleteagressif (ex.{ age: 3600, count: 1000 }).removeOnFailencore plus agressif une fois que vous êtes passé à un pattern DLQ.- Activez la persistance AOF de Redis —
appendfsync everysecest le point idéal documenté pour BullMQ. (BullMQ production) maxRetriesPerRequest: nullsur le worker pour empêcher ioredis de lancer une exception lors de déconnexions transitoires. (BullMQ production)- Côté file d'attente :
enableOfflineQueue: falsepour que le producteur échoue rapidement au lieu de mettre en mémoire tampon.
Arrêt gracieux
process.on('SIGTERM', async () => {
await worker.close(); // arrêter d'accepter ; terminer les tâches en cours.
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* même chose */ });
Le guide de production BullMQ note explicitement : fermez les workers avant d'arrêter le processus ; le timeout de blocage par défaut est d'environ 30s. (BullMQ production)
Décision file d'attente vs workflow
Un cadre utile tiré de la documentation de Temporal : "Contrairement aux files d'attente de messages qui déplacent des données entre les services, Temporal orchestre des processus entiers... sait où vous en êtes dans un workflow, ce qui est terminé, ce qui est en attente et ce qui doit être renvoyé." (Blog Temporal)
flowchart TD
A[Tâche d'arrière-plan] --> B{Effet secondaire unique ou pipeline ?}
B -->|Unique| C[BullMQ / Sidekiq / SQS]
B -->|Multi-étapes| D{Les étapes durent des secondes ou des heures+ ?}
D -->|Secondes| E[BullMQ flows / Sidekiq batches]
D -->|Heures+ ou intervention humaine| F[Temporal / DBOS / Restate]
C --> G{Besoin d'isolation multi-région ou multi-tenant ?}
G -->|Oui| H[SQS ou Temporal — un cluster Redis unique ne suffira pas]
G -->|Non| I[Utilisez avec cluster Redis + AOF + noeviction]
Anti-patterns
Idempotence via Redis SET-NX uniquement
Symptôme : Lors d'un défaut ou d'un redémarrage Redis, la même tâche s'exécute deux fois et crée des effets secondaires dupliqués (facturation, email, ticket).
Diagnostic : Redis est un cache ; il peut être évincé, redémarré ou partitionné. La déduplication SET-NX qui vit uniquement dans Redis est au mieux une tentative.
Correctif : Clé d'idempotence avec une contrainte unique en base de données sur l'enregistrement d'effet secondaire (ex. email_sends.idempotency_key). La transaction DB qui crée l'effet secondaire EST la primitive de déduplication.
lockDuration plus court que le p99 du handler
Symptôme : Entrées de journal de tâches bloquées ; même tâche traitée par deux workers. Les clients se plaignent.
Diagnostic : La valeur par défaut de lockDuration est de 30s ; le handler prend parfois 45s ; le deuxième worker la récupère.
Correctif : Mesurez le p99, définissez lockDuration à ~3x le p99, et appelez job.extendLock() dans les handlers longs. Ou divisez le travail.
Backoff exponentiel simple (sans jitter)
Symptôme : Un défaut aval devient une panne aval ; 10k tâches martèlent le service en récupération en parfaite synchronisation. Diagnostic : Sans jitter, les renvois sont corrélés. Correctif : Backoff avec jitter complet. Plafonnez le délai maximum pour limiter le temps jusqu'à l'échec dans la DLQ.
maxmemory-policy allkeys-lru sur Redis
Symptôme : La file d'attente perd mystérieusement des tâches sous charge. Pas d'erreurs. (BullMQ production)
Diagnostic : Redis a évincé les clés de tâches pour libérer de l'espace.
Correctif : CONFIG SET maxmemory-policy noeviction ET vérifiez-le au démarrage. Les workers doivent refuser de démarrer autrement.
Prétendre à l'exactement-une-fois entre les systèmes
Symptôme : Client facturé deux fois ; deux fois le même email envoyé. L'ingénieur est sûr que "le courtier est exactement-une-fois." Diagnostic : Même un courtier FIFO/exactement-une-fois n'étend pas sa garantie à votre DB + fournisseur d'email + Stripe + grand livre. Correctif : Traitez la file d'attente comme au-moins-une-fois. Idempotence à chaque frontière d'effet secondaire.
DLQ sans outil de rejeu
Symptôme : "Nous avons 3 200 tâches dans la DLQ. Nous ne savons pas ce qu'il y a dedans." La DLQ devient un cimetière non surveillé. Diagnostic : Construit la DLQ, n'a pas construit l'outil d'exploitation. Correctif : Rejeu par ID, rejeu par plage de temps, vidage avec confirmation. CLI ou interface d'administration. Testé.
Travail bloquant long sur la boucle d'événements
Symptôme : Le processus du worker BullMQ se bloque ; les heartbeats s'arrêtent ; le verrou expire ; la tâche se réexécute.
Diagnostic : Travail lié au CPU dans le même processus que le heartbeat de la file d'attente ; la boucle d'événements bloque pendant > lockDuration.
Correctif : Lancez un processus enfant ou un thread de travail pour le travail lié au CPU. Ou exécutez un processeur en bac à sable (BullMQ prend en charge le bac à sable par tâche).
Critères de qualité
- Test : test de chaos — tuez un worker en cours de tâche ; vérifiez que la tâche se réexécute et que l'effet secondaire n'est pas dupliqué.
- Test : test de tempête de renvois — faites échouer le système aval pendant 60s ; vérifiez que le backoff + jitter étale les renvois (pas de troupeau piétinant dans les métriques).
- Chaque handler est idempotent et utilise une clé d'idempotence stable dérivée de l'événement métier.
- Une contrainte unique au niveau DB sauvegarde toute déduplication dans Redis (jobId, SET-NX).
-
lockDuration/ timeout de visibilité ≥ 3× la latence p99 mesurée du handler, ET les handlers longs prolongent le verrou. - Les erreurs permanentes (classe
PermanentErrorou équivalent) contournent le renvoi et vont directement dans la DLQ. - Alertes DLQ : alerter lorsque le taux de croissance > N par minute, pas seulement lorsque le nombre > 0.
- L'outil de rejeu DLQ existe et est testé (ponctuel + par plage).
- Redis :
maxmemory-policy=noeviction, AOF activé (appendfsync everysec), vérifié au démarrage du worker. (BullMQ production) -
removeOnCompleteetremoveOnFailconfigurés. La métrique du nombre de tâches est suivie dans le temps.