Skip to Content
SymfonyPartie 12 — Temps réel & asynchrone12.2 — Async, retries, échecs & workers

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 va un message : sync (immédiat) ou async (une file : Doctrine, Redis, AMQP/RabbitMQ…). On route chaque message vers un transport dans messenger.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 :

TransportDSNNote
Doctrinedoctrine://defaultfile en base — simple, sans dépendance externe
Redisredis://localhost:6379/messagesrapide, léger
AMQPamqp://guest:guest@localhost/%2f/messagesRabbitMQ — 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’interface MessageHandlerInterface/la config manuelle. Si vos souvenirs incluent implements 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=always

Bonnes 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’EntityManager accumule 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> # jeter

C’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: async

Puis 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.