Aller au contenu principal
Retour au blog

Symfony Messenger : workers, retry et monitoring — 0 perte en prod

Flavien Métivier27 août 20248 min

Ton application Symfony traite des commandes, envoie des emails, génère des PDFs ou indexe dans Elasticsearch — et tu le fais en synchrone. Résultat : tes utilisateurs attendent, tes timeouts s'accumulent, et le premier pic de charge t'envoie une alerte à 3 h du matin. Symfony Messenger est la réponse architecturale à ce problème. Depuis la sortie de Symfony 7.1 le 29 mai 2024, le composant est officiellement sous BC promise, ce qui signifie une chose concrète : tu peux construire dessus sans craindre que la prochaine mise à jour te casse la production. Ce guide couvre tout ce dont tu as besoin pour passer de « ça marche en local » à « zéro perte de message en prod ».

Ce que change le BC promise de Symfony 7.1

Avant Symfony 7.1, Messenger était fonctionnel mais certaines parties de son API publique restaient exposées à des changements entre versions mineures. Avec la release du 29 mai 2024, le composant intègre la Backward Compatibility promise de Symfony : toute API publique est garantie stable jusqu'à Symfony 8. Pour toi en tant que lead dev ou CTO externalisé, ça veut dire que le code que tu écris aujourd'hui ne sera pas cassé par une mise à jour mineure. C'est le signal qu'attendaient beaucoup d'équipes pour déplacer leurs jobs asynchrones depuis des outils tiers — scripts cron bricolés, SDK RabbitMQ maison — vers Messenger. La base de code est mature, battle-tested en production par des milliers d'applications, et l'écosystème de bundles stables autour de lui est solide. Le moment d'y investir est maintenant.

Choisir son transport : Doctrine, Redis ou AMQP ?

Le transport est la pièce centrale de ton architecture Messenger : c'est lui qui stocke les messages entre l'émetteur et le worker. Symfony supporte trois options principales, et le choix doit être guidé par ton niveau de charge, ton infrastructure existante et tes exigences de durabilité des messages.

  • Doctrine — La solution zéro infrastructure. Les messages sont stockés dans une table de ta base de données existante. Idéal pour démarrer ou pour des volumes faibles (moins de 1 000 messages/heure). Le polling DB peut devenir un goulot d'étranglement sous charge. À éviter si ta DB est déjà sous tension, ou si tes messages doivent survivre à un rollback de transaction.
  • Redis Streams — Rapide, faible latence, support natif des Consumer Groups qui empêchent deux workers de traiter le même message. Bon compromis pour des charges moyennes. Point de vigilance : la durabilité dépend entièrement de ta config AOF/RDB. Un Redis sans persistance sur disque = messages perdus au redémarrage.
  • AMQP / RabbitMQ — Le choix production-grade. Dead-letter exchanges natifs, routing avancé par binding key, acknowledgements explicites, gestion fine des priorités. Obligatoire au-delà de quelques milliers de messages/heure ou dès que tu as des exigences SLA strictes. La complexité d'opération est plus élevée, mais les garanties sont incomparables.
# Exemples de DSN à définir dans .env ou .env.local

# Doctrine (table messenger_messages dans ta DB par défaut)
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async

# Redis Streams (consumer group symfony, consumer ID auto)
MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages/symfony/consumer

# AMQP / RabbitMQ (vhost /, exchange messages)
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@rabbitmq:5672/%2f/messages

Configurer Messenger pour la production

Un message Symfony Messenger est un simple objet PHP — idéalement une classe readonly (PHP 8.1+, idiomatique en 8.3) pour garantir l'immuabilité. Le handler est découvert automatiquement via l'attribut #[AsMessageHandler], plus besoin de tag YAML manuel. Voici un exemple concret de bout en bout :

<?php
// src/Message/ProcessOrderMessage.php
namespace App\Message;

final readonly class ProcessOrderMessage
{
    public function __construct(
        public int               $orderId,
        public string            $customerId,
        public \DateTimeImmutable $requestedAt = new \DateTimeImmutable(),
    ) {}
}
<?php
// src/MessageHandler/ProcessOrderMessageHandler.php
namespace App\MessageHandler;

use App\Message\ProcessOrderMessage;
use App\Service\OrderService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class ProcessOrderMessageHandler
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly OrderService    $orderService,
    ) {}

    public function __invoke(ProcessOrderMessage $message): void
    {
        $this->logger->info('Processing order', [
            'orderId'    => $message->orderId,
            'customerId' => $message->customerId,
        ]);

        $this->orderService->process($message->orderId, $message->customerId);
    }
}
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 5
                    delay: 1000       # délai initial en millisecondes
                    multiplier: 2     # backoff exponentiel
                    max_delay: 300000 # plafond à 5 minutes

            failed:
                dsn: 'doctrine://default?queue_name=failed'
                retry_strategy:
                    max_retries: 0    # on ne retente pas depuis la dead queue

        routing:
            App\Message\ProcessOrderMessage: async
            App\Message\SendEmailMessage:    async
            App\Message\GeneratePdfMessage:  async

Retry et backoff exponentiel : ne plus jamais perdre un message

Un message qui échoue ne doit jamais disparaître en silence. La stratégie de retry de Symfony Messenger combine deux mécanismes complémentaires. Le backoff exponentiel évite l'effet « thundering herd » : si un service tiers est temporairement indisponible, relancer immédiatement 10 workers en parallèle ne fait qu'aggraver la situation. Avec multiplier: 2 et delay: 1000, les tentatives suivent le pattern 1 s → 2 s → 4 s → 8 s → 16 s jusqu'à atteindre le max_delay. Le failure transport est l'autre filet de sécurité : après épuisement des retries, le message est déplacé dans une file dédiée — il n'est pas perdu, il attend une intervention humaine. Ajoute une alerte sur « taille de la failed queue > 0 depuis plus de 10 minutes » et tu couvres la quasi-totalité des scénarios de défaillance.

Pour injecter un délai personnalisé depuis ton handler — par exemple, une API tierce te répond HTTP 429 avec un header Retry-After de 10 minutes — tu as trois approches selon la situation :

Besoin d'un expert Symfony ?

Réserver un appel
<?php
// Dans ton handler, dispatch proactif avec délai explicite
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Messenger\MessageBusInterface;

// Cas 1 : laisser le retry automatique gérer (recommandé)
// Lance simplement une exception, Messenger applique le backoff configuré
throw new \RuntimeException('API rate limited — retry via backoff automatique');

// Cas 2 : replanifier manuellement avec délai précis
$this->bus->dispatch(
    new ProcessOrderMessage($message->orderId, $message->customerId),
    [new DelayStamp(600_000)] // 10 minutes en millisecondes
);

// Cas 3 : marquer le message pour ne PAS retenter
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
throw new UnrecoverableMessageHandlingException(
    'Commande introuvable, abandon définitif'
);

Superviser les workers avec Supervisor

Un worker Messenger est un processus PHP de longue durée. Sans supervision, il meurt et personne ne le relance — tes messages s'accumulent dans la queue sans être traités, jusqu'à ce qu'un humain s'en aperçoive. Supervisor est l'outil de référence pour cette tâche : il surveille les processus, les redémarre automatiquement en cas de crash et centralise les logs. L'option --time-limit est critique : elle force le worker à se terminer proprement après X secondes, une fois le message en cours terminé, évitant ainsi les fuites mémoire progressives. Couple-la avec --memory-limit comme second filet de sécurité.

; /etc/supervisor/conf.d/messenger-worker.conf
[program:messenger-worker]
command=/usr/bin/php /var/www/app/bin/console messenger:consume async --time-limit=3600 --memory-limit=128M --sleep=1
directory=/var/www/app
user=www-data
numprocs=4
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=5
stopwaitsecs=60
stopsignal=SIGTERM
stderr_logfile=/var/log/supervisor/messenger-worker.err.log
stdout_logfile=/var/log/supervisor/messenger-worker.out.log

; Rotation des logs pour ne pas saturer le disque
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=3
stderr_logfile_maxbytes=10MB
stderr_logfile_backups=5
# Recharger la configuration après modification ou déploiement
sudo supervisorctl reread && sudo supervisorctl update

# Vérifier le statut de tous les workers
sudo supervisorctl status messenger-worker:*

# Redémarrer tous les workers proprement après un déploiement
# (SIGTERM = fin propre après le message en cours, pas de perte)
sudo supervisorctl restart messenger-worker:*

# Voir les logs en temps réel pour debug
sudo supervisorctl tail -f messenger-worker:messenger-worker_00 stdout

Ajuste numprocs selon ta charge et la nature de tes traitements. Commence à 2-4 workers pour un usage standard. Si tes messages sont I/O-bound (appels API, emails, requêtes DB), tu peux monter à 8-16 sans saturer tes CPU. Pour des traitements CPU-bound (encodage, génération de PDF complexes), reste proche du nombre de cœurs disponibles. En environnement Docker, assure-toi que le conteneur qui exécute Supervisor a accès aux mêmes variables d'environnement que ton app web — le piège classique est un DATABASE_URL manquant dans le conteneur worker.

Monitoring et observabilité des queues

Savoir que tes workers tournent ne suffit pas. Tu dois savoir combien de messages attendent, combien ont échoué et depuis combien de temps. Symfony expose des événements dédiés que tu peux brancher sur ton backend de métriques — Prometheus, Datadog, ou même un simple compteur en Redis. La clé est l'événement WorkerMessageFailedEvent : il expose un booléen willRetry() qui te permet de distinguer un échec temporaire (retry en cours) d'un message définitivement abandonné vers la dead queue. Ce second cas mérite une alerte immédiate.

<?php
// src/EventSubscriber/MessengerMonitoringSubscriber.php
namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent;

final class MessengerMonitoringSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
        // Adapte à ton backend : Prometheus client, Datadog DogStatsD, etc.
        private readonly MetricsCollectorInterface $metrics,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            WorkerMessageReceivedEvent::class => 'onReceived',
            WorkerMessageHandledEvent::class  => 'onHandled',
            WorkerMessageFailedEvent::class   => 'onFailed',
        ];
    }

    public function onReceived(WorkerMessageReceivedEvent $event): void
    {
        $class = $event->getEnvelope()->getMessage()::class;
        $this->metrics->increment('messenger.received', ['message' => $class]);
    }

    public function onHandled(WorkerMessageHandledEvent $event): void
    {
        $class = $event->getEnvelope()->getMessage()::class;
        $this->metrics->increment('messenger.handled', ['message' => $class]);
    }

    public function onFailed(WorkerMessageFailedEvent $event): void
    {
        $class     = $event->getEnvelope()->getMessage()::class;
        $willRetry = $event->willRetry();

        $this->metrics->increment('messenger.failed', [
            'message'    => $class,
            'will_retry' => $willRetry ? 'yes' : 'no',
        ]);

        $logContext = [
            'class'      => $class,
            'will_retry' => $willRetry,
            'error'      => $event->getThrowable()->getMessage(),
        ];

        if ($willRetry) {
            $this->logger->warning('Message failed, retry scheduled', $logContext);
        } else {
            // Dernier filet : message en dead queue, intervention humaine requise
            $this->logger->critical('Message moved to failed queue — action required', $logContext);
        }
    }
}

Opérer la dead queue : les commandes indispensables

La dead queue n'est pas une poubelle — c'est une liste d'attente pour l'intervention humaine. Symfony Messenger fournit des commandes CLI pour l'inspecter et rejouer les messages sans toucher à la base de données ni au transport directement. La commande messenger:stop-workers est particulièrement importante lors des déploiements : elle envoie un signal SIGTERM à tous les workers actifs, qui terminent leur message en cours avant de s'arrêter proprement — aucune perte, aucune corruption.

# Lister les messages en échec dans la dead queue
php bin/console messenger:failed:show

# Afficher le détail d'un message (contenu + trace de l'exception)
php bin/console messenger:failed:show <id> --transport=failed

# Retraiter un message spécifique
php bin/console messenger:failed:retry <id>

# Retraiter tous les messages en échec (à lancer après correction du bug)
php bin/console messenger:failed:retry --all

# Supprimer définitivement un message non récupérable
php bin/console messenger:failed:remove <id>

# Arrêt propre de tous les workers (pour un déploiement)
# Chaque worker finit son message en cours avant de s'arrêter
php bin/console messenger:stop-workers

Ce que tu mets en production dès maintenant

Ce guide couvre les quatre piliers d'un setup Messenger solide : le transport adapté à ton infrastructure, la configuration avec retry et failure transport, la supervision via Supervisor pour garantir la disponibilité des workers, et l'observabilité pour savoir en temps réel ce qui se passe dans tes queues. Avec le BC promise de Symfony 7.1, cet investissement est pérenne. Le schéma minimal opérationnel tient en quatre lignes : un transport configuré, un failure_transport, un fichier Supervisor, et un subscriber sur WorkerMessageFailedEvent. Le reste est de l'optimisation. Si tu veux aller plus loin sur l'architecture asynchrone dans tes projets Symfony — choix de transport, stratégie de retry sur mesure, intégration avec ton stack de monitoring existant — abonne-toi à la newsletter pour recevoir les prochains guides techniques en avant-première.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.