Chapitre 12.2 — Async, retries, échecs & workers
Partie 12 : Temps réel & async — Chapitre 2/7 Version de référence : Symfony 7.4 (Messenger).
⏱️ TL;DR
- Un transport décide où va un message :
sync(immédiat) ou async (une file : Doctrine, Redis, AMQP/RabbitMQ…). On route chaque message vers un transport dansmessenger.yaml.- Un worker consomme la file :
php bin/console messenger:consume async. En prod, un superviseur (systemd/supervisor) le maintient en vie.- En cas d’échec, Messenger réessaie (retry strategy : nombre, délai, multiplicateur), puis envoie le message dans une file d’échec (
failed) — inspectable et rejouable.- Les handlers doivent être idempotents (rejouables sans double effet), car un message peut être retenté.
- Au déploiement, on arrête proprement les workers (
messenger:stop-workers) pour qu’ils rechargent le nouveau code.- Pour un dev Next.js : le transport ≈ le broker (Redis/SQS), le worker ≈ le process consumer, la failure queue ≈ la dead-letter queue.
🎯 Objectifs
- Router un message vers un transport async.
- Lancer et superviser un worker.
- Configurer retries et file d’échec.
- Rendre un handler idempotent et gérer le déploiement.
1. Les transports
Un transport est une destination de messages. On les configure dans messenger.yaml, avec un DSN :
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%' # ex. redis://localhost:6379/messages
failed: 'doctrine://default?queue_name=failed'
failure_transport: failed # les messages définitivement échoués atterrissent ici
routing:
App\Message\SendBookingConfirmation: async
App\Message\SendReminder: async
# non routé → traité en synchrone (par défaut)DSN courants :
| Transport | DSN | Note |
|---|---|---|
| Doctrine | doctrine://default | file en base — simple, sans dépendance externe |
| Redis | redis://localhost:6379/messages | rapide, léger |
| AMQP | amqp://guest:guest@localhost/%2f/messages | RabbitMQ — robuste, priorités, routing avancé |
Pour démarrer (BookLab), le transport Doctrine suffit (aucun service à ajouter). En montée en charge, on passe à Redis ou RabbitMQ.
⚡ Depuis Symfony 5 : Messenger existait, mais l’écosystème s’est enrichi (transports Amazon SQS, Beanstalkd, le Scheduler intégré — chapitre 12.3, la reprise sur échec améliorée). L’API
#[AsMessageHandler](attribut) a remplacé l’interfaceMessageHandlerInterface/la config manuelle. Si vos souvenirs incluentimplements MessageHandlerInterface, c’est l’attribut désormais.
2. Le worker : consommer la file
Un worker est un processus qui consomme les messages d’un transport async :
php bin/console messenger:consume async -vv
# -vv : mode verbeux (voir chaque message traité)Ce processus tourne en continu : il prend les messages de la file async et exécute leurs handlers. En dev, on le lance à la main. En production, il doit être maintenu en vie par un superviseur.
3. Superviser les workers en production
Un worker peut mourir (erreur fatale, redémarrage serveur). Un superviseur le relance automatiquement. Exemple systemd :
# /etc/systemd/system/booklab-worker@.service (extrait)
[Service]
ExecStart=php /var/www/booklab/bin/console messenger:consume async --time-limit=3600
Restart=alwaysBonnes pratiques : --time-limit (le worker se termine après N secondes et est relancé — évite les fuites mémoire d’un process trop long), --memory-limit, et plusieurs workers en parallèle pour le débit. (En FrankenPHP worker mode, Partie 15, il existe aussi des options intégrées.)
⚠️ Piège fuite mémoire : un worker est un process long-running — contrairement au modèle « une requête = état neuf » de PHP (Partie 1). L’
EntityManageraccumule des entités, la mémoire grimpe. Parades :--time-limit/--memory-limit(le process redémarre régulièrement), et$em->clear()dans les handlers de gros volumes. Ne laissez jamais un worker tourner indéfiniment sans limite.
4. Retries et file d’échec
Un handler peut échouer (API tierce indisponible, erreur transitoire). Messenger réessaie selon une stratégie :
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1s avant le 1er retry
multiplier: 2 # 1s, 2s, 4s (backoff exponentiel)Après max_retries échecs, le message part dans le failure transport (failed) — une file d’échec où il est conservé (pas perdu) et inspectable :
php bin/console messenger:failed:show # lister les messages échoués
php bin/console messenger:failed:show <id> -vv # détail + trace de l'erreur
php bin/console messenger:failed:retry <id> # rejouer après correction
php bin/console messenger:failed:remove <id> # jeterC’est la dead-letter queue : on ne perd rien, on diagnostique, on rejoue.
5. Idempotence des handlers
Comme un message peut être retenté (retry, ou livraison « at-least-once » de certains brokers), un handler doit être idempotent : le rejouer ne doit pas produire deux effets.
public function __invoke(SendBookingConfirmation $message): void
{
$booking = $this->bookings->find($message->bookingId);
if ($booking === null || $booking->isConfirmationSent()) {
return; // déjà envoyé → ne pas renvoyer
}
// ... envoyer + marquer comme envoyé
$booking->markConfirmationSent();
$this->em->flush();
}⚠️ Piège : un handler non idempotent (ex. « débiter la carte ») rejoué double l’effet. Concevez les handlers pour tolérer un rejeu : vérifier un état (« déjà fait ? »), utiliser une clé d’idempotence, ou des opérations naturellement idempotentes. C’est crucial pour les paiements (chapitre 12.7).
6. Déploiement : arrêter proprement les workers
Un worker charge le code en mémoire au démarrage : après un déploiement, il exécute encore l’ancien code. On lui demande de s’arrêter proprement (il finit le message en cours puis quitte, et le superviseur le relance avec le nouveau code) :
php bin/console messenger:stop-workers # signale à tous les workers de s'arrêter après le message courantÀ intégrer dans le script de déploiement (Partie 15).
✏️ Exercices
Exercice 1. Routez SendBookingConfirmation vers un transport async Doctrine, avec 3 retries en backoff exponentiel. Écrivez la config.
✅ Solution
framework:
messenger:
transports:
async:
dsn: 'doctrine://default'
retry_strategy: { max_retries: 3, delay: 1000, multiplier: 2 }
failed: 'doctrine://default?queue_name=failed'
failure_transport: failed
routing:
App\Message\SendBookingConfirmation: asyncPuis un worker : php bin/console messenger:consume async. En prod, superviser + --time-limit.
Exercice 2. Un worker de traitement voit sa mémoire grimper jusqu’au crash. Deux causes/solutions ?
✅ Solution
Cause : le worker est long-running ; l’EntityManager (et d’autres caches) accumulent des objets au fil des messages. Solutions : (1) lancer avec --time-limit/--memory-limit pour que le process redémarre régulièrement (le superviseur le relance) ; (2) appeler $em->clear() dans les handlers de gros volumes pour détacher les entités traitées. Éventuellement, réduire ce que chaque handler garde en mémoire.
Exercice 3. Pourquoi un handler de paiement doit-il être idempotent, et comment l’assurer ?
✅ Solution
Parce qu’un message peut être retenté (retry, livraison at-least-once) : un handler non idempotent débiterait deux fois la carte. On l’assure en vérifiant un état avant d’agir (« ce paiement est-il déjà traité ? » via un id de transaction/clé d’idempotence stockée), de sorte qu’un rejeu n’a aucun effet supplémentaire. Stripe fournit d’ailleurs des clés d’idempotence (chapitre 12.7).
🧠 Quiz de révision
1. Qu’est-ce qu’un transport et lequel choisir pour débuter ?
La destination d’un message (sync ou une file async). Pour débuter : Doctrine (doctrine://default), sans service externe ; puis Redis/RabbitMQ à l’échelle.
2. Comment traite-t-on les messages async ?
Avec un worker : php bin/console messenger:consume async, maintenu en vie par un superviseur (systemd/supervisor) en production.
3. Que se passe-t-il après trop d’échecs d’un message ?
Après max_retries, il part dans le failure transport (file d’échec) : conservé, inspectable (messenger:failed:show), rejouable (retry) ou jetable (remove).
4. Pourquoi un handler doit-il être idempotent ?
Parce qu’un message peut être retenté : un handler idempotent, rejoué, ne produit pas d’effet en double (crucial pour les paiements).
5. Que faire des workers au déploiement ?
Les arrêter proprement (messenger:stop-workers) pour qu’ils rechargent le nouveau code (ils finissent le message courant puis sont relancés).
Prochain chapitre : 12.3 — Scheduler — planifier des tâches récurrentes sans cron OS.