Blog
Symfony Messenger — Quand le même message est traité deux fois
Un worker peut terminer l’opération métier et recevoir pourtant le même message à nouveau. La protection consiste à rendre la répétition sûre, pas à supposer qu’elle n’arrivera jamais.
La commande est traitée. Pourquoi le message revient-il ?
Prenons une application qui, à la réception de OrderCreated, envoie une confirmation, crée une facture et met à jour la commande. Le prestataire de messagerie accepte l’e-mail, mais le processus de traitement s’arrête avant de confirmer la fin de l’exécution au transport. Le message peut alors revenir, alors qu’une partie du travail a abouti.
Il s’agit d’un exemple hypothétique, pas du récit d’un incident chez GiSoft. Il révèle une difficulté concrète : le prestataire, la base de l’application et le transport de messages peuvent chacun connaître une partie différente du résultat. L’absence d’acquittement n’annule ni un e-mail envoyé ni une facture dont l’enregistrement a été validé.
Pour un transport asynchrone qui remet à disposition les messages non acquittés, le décalage peut prendre cette forme :
Instant Effet métier Transport
A E-mail accepté Traitement en cours
B Arrêt du processus Aucun acquittement
C Effet toujours présent Nouvelle remise
possibleLe mécanisme dépend du transport et de sa configuration. Une connexion perdue, l’expiration d’une période d’invisibilité du message ou d’un délai de remise à disposition n’ont pas forcément les mêmes conséquences. Messenger n’offre pas une garantie de livraison universelle pour tous les transports, traitement synchrone compris.
Une nouvelle tentative après une exception du gestionnaire et une nouvelle remise après la disparition d’un processus sont aussi deux mécanismes distincts. Tous deux peuvent toutefois relancer la même opération métier.
Réussite partielle : e-mail et export de facture
Les extraits suivants utilisent des services de projet hypothétiques. Les constructeurs, déclarations de propriétés, classes de messages et enregistrements des gestionnaires dans Messenger sont omis. Des méthodes telles que sendOrderConfirmation() et transaction->run() sont des contrats applicatifs, pas des API Symfony ou des méthodes d’un SDK fournisseur.
Ce gestionnaire de confirmations de commande ne protège pas contre les doublons :
final class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->get($message->orderId);
$this->mailer->sendOrderConfirmation($order);
}
}On suppose que orders->get() renvoie une commande existante ou lève une exception, et que l’adaptateur d’e-mail soumet directement la demande au prestataire. S’il se contente de placer un autre message dans une file, l’incertitude se déplace vers le traitement de ce dernier.
Un indicateur local « envoyé » ne suffit pas à combler l’écart. L’enregistrer avant l’envoi peut faire ignorer un e-mail jamais soumis ; l’enregistrer après laisse une possibilité de doublon. Le mécanisme d’idempotence du prestataire peut dédupliquer les demandes d’envoi dans les conditions qu’il documente. Il ne garantit pas universellement qu’exactement un e-mail parviendra au destinataire.
L’export d’une facture présente un risque similaire :
public function __invoke(CreateInvoice $message): void
{
$invoice = $this->invoiceService->create($message->orderId);
$this->externalAccounting->send($invoice);
$this->repository->markAsExported($invoice);
}Le système comptable peut accepter la facture avant l’échec de markAsExported(). Si la stratégie configurée prévoit une nouvelle tentative, le gestionnaire reprend depuis le début, pas à la dernière ligne. Retrouver une facture locale peut éviter un second enregistrement dans l’application, mais pas nécessairement une seconde pièce dans le système comptable. L’export a besoin de sa propre identité stable et d’un moyen de vérifier le résultat externe.
Identifier l’opération, pas la tentative
Il faut distinguer quatre éléments : le message de transport, l’opération métier demandée, une tentative d’exécution et l’effet produit. Deux messages peuvent demander la même opération. À l’inverse, une commande peut légitimement nécessiter plusieurs opérations différentes.
L’idempotence signifie ici que répéter la même opération ne produit pas d’effets métier supplémentaires non souhaités. Elle n’exige pas que le code PHP ne s’exécute physiquement qu’une seule fois.
Considérons cet appel de paiement incomplet :
$paymentGateway->charge($order->getAmount());La méthode appartient à un adaptateur hypothétique. La devise, l’autorisation et la gestion des doublons ne sont pas représentées. Une clé telle que charge-OP-2026-00045 peut identifier une opération de paiement autorisée. Cette identité est enregistrée à la création de l’opération, puis conservée pour ses nouvelles tentatives, remises et reprises manuelles. Le compteur de tentatives du transport ne doit pas servir à générer une nouvelle clé à chaque essai.
Une nouvelle tentative de paiement, autorisée comme une opération distincte, demande un autre identifiant. L’incertitude sur le résultat précédent ne justifie pas, à elle seule, cette création. De même, invoice-order-123 n’identifie la facture voulue que si le modèle métier en prévoit une de ce type par commande ; reservation-928 peut identifier une réservation de stock précise.
La portée de la clé chez le prestataire, sa durée de conservation, les contrôles de paramètres et les possibilités de consultation du résultat comptent. Un identifiant aléatoire convient s’il est enregistré une fois pour l’opération, puis réutilisé. En générer un à chaque envoi empêche la déduplication. La conservation des traces locales doit également tenir compte de la période pendant laquelle une reprise reste possible. L’identifiant doit être lié au type d’opération, à l’entité et aux paramètres pertinents. Le même identifiant associé à une autre demande constitue un conflit, pas un doublon traité avec succès.
Une transaction locale ne reconnaît pas un doublon
Même une opération limitée à la base peut être appliquée deux fois :
$stock->decrease(1);
$entityManager->flush();Ici, $stock est une entité gérée représentant le stock, et decrease(1) réduit la quantité. Avec dix unités au départ, deux exécutions validées peuvent en laisser huit au lieu de neuf. Chaque transaction peut être correcte prise séparément. flush() transmet les modifications à la base ; en présence d’une transaction englobante, sa validation finale reste une étape distincte.
Le GenerateInvoiceHandler suivant recherche un doublon lors d’exécutions successives. Ce n’est pas une implémentation complète sûre en concurrence :
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
$this->transaction->run(function () use ($message): void {
$operationId = $message->operationId;
if ($this->processedOperations->exists($operationId)) {
return;
}
$this->invoiceService->generate($message->orderId);
$this->processedOperations->markProcessed($operationId);
});
}
}Dans cet exemple, generate() doit uniquement modifier la base locale. La facture et la trace de l’opération terminée doivent être validées ou annulées ensemble, sur la même connexion transactionnelle. L’implémentation omise de run() doit réellement assurer cette frontière et écrire les modifications ORM en attente avant la validation.
Deux processus peuvent constater simultanément l’absence de trace et tous deux entrer dans generate(). Le contrôle exists() est donc une optimisation, pas une garantie. Une mise en œuvre possible impose une clé d’opération unique et non nullable dans la base. Si l’insertion de la trace de fin rencontre un conflit, la transaction perdante doit aussi annuler ses modifications de facture. Le doublon se traite hors de la transaction en échec, avec un contexte de persistance utilisable et après vérification de l’opération déjà validée. Une autre violation de contrainte ne doit pas être considérée comme un doublon traité avec succès.
Une réservation atomique de l’opération ou un verrou adapté constituent d’autres approches. L’extrait ne les fournit pas. Une annulation locale ne retirerait pas non plus une requête externe déjà envoyée par generate().
Lorsque le métier impose réellement une facture par commande, une contrainte comme invoice.order_id UNIQUE apporte une seconde protection, distincte. La colonne doit interdire NULL et la portée de l’unicité doit correspondre au modèle. Des factures partielles ou des avoirs peuvent nécessiter une autre règle. La contrainte protège des enregistrements locaux, pas un e-mail ou un paiement déjà accepté ailleurs.
Paiements concurrents et résultats incertains
Une vérification sur un objet en mémoire ne coordonne pas les processus :
if ($order->isPaid()) {
return;
}
$order->markAsPaid();Cet extrait modifie l’état local ; il ne débite pas le client et ne prouve pas le paiement. Si un tel contrôle protège un appel de paiement, deux processus peuvent tous deux lire « non payé » avant qu’un des deux n’enregistre sa modification, puis demander chacun un débit.
Une mise à jour conditionnelle atomique peut réserver le traitement à un processus :
UPDATE orders
SET payment_started = true
WHERE id = :id
AND payment_started = false;Ce SQL paramétré suppose un identifiant de commande unique et une colonne booléenne non nullable, initialement à false. Dans ce protocole, seul le processus dont la mise à jour a modifié une ligne peut poursuivre. Zéro ligne modifiée ne signifie pas « paiement réussi ». La réservation doit être validée avant que la suite s’appuie dessus ; les autres règles de paiement restent nécessaires.
L’indicateur ne conserve pas le résultat externe et ne rétablit pas un processus interrompu. La reprise doit distinguer un arrêt avant soumission d’un arrêt après acceptation du paiement par le prestataire. Réinitialiser simplement l’indicateur et débiter à nouveau recrée le risque initial. Il faut encore une trace durable de l’opération, la même clé chez le prestataire et une procédure définie de rapprochement du résultat.
Un délai HTTP dépassé peut lui aussi laisser le résultat inconnu. Le prestataire peut avoir terminé l’opération avant la perte de sa réponse. Lorsque l’API le permet, le résultat se vérifie par référence externe ou identifiant d’opération, en respectant les règles de nouvelle tentative du prestataire. S’il ne peut pas être établi de façon sûre, il faut arrêter les tentatives automatiques et transmettre le dossier pour examen.
Une transaction Doctrine ordinaire couvre les modifications de base qui y participent. Elle n’annule pas en même temps les opérations d’une API de paiement, d’un serveur de messagerie ou d’un courtier de messages. Garder un verrou de base pendant la requête HTTP ne crée pas non plus cette garantie.
Publication après validation ou via une outbox ?
Un transport asynchrone indépendant peut rendre un message disponible avant la validation de la transaction associée. Par exemple, le producteur appelle persist() puis flush() pour une Order dans une transaction ouverte, avant d’envoyer OrderCreated. Un autre processus interroge une connexion distincte et ne voit pas encore la commande ; le producteur valide seulement ensuite. La visibilité dépend du transport, du niveau d’isolation et de la participation effective à la transaction.
Reporter l’envoi après la validation résout ce problème d’ordre. Mais un message différé uniquement en mémoire peut encore être perdu si le processus s’arrête entre la validation et la publication. « Après le traitement sur le bus courant » ne signifie pas automatiquement « après toute transaction de base » : l’ordre des middlewares et la responsabilité de la transaction comptent. Symfony documente l’ordre requis entre dispatch_after_current_bus et doctrine_transaction.
Une outbox transactionnelle conserve durablement l’intention de publication avec la modification métier. Le schéma représente cette frontière ; ce n’est pas du SQL exécutable :
Une transaction dans la base locale
Enregistrer Order
Enregistrer OutboxMessage(OrderCreated)
COMMIT
--------------------------------------------
Après validation
Processus d’envoi -> transport -> consommateur
Publication ou remise pouvant se répéter
Le consommateur protège son opération métierLes deux enregistrements doivent participer à la même transaction de base appropriée. Un processus de publication distinct lit ensuite les entrées validées et enregistre sa progression. S’il envoie un message puis s’arrête avant d’en noter le succès, il peut le publier à nouveau. L’outbox lie l’enregistrement de la modification à celui de l’intention de publication. Elle exige encore une publication surveillée et des consommateurs protégés contre les doublons, pas une hypothèse d’exécution exactement une fois.
Garder des frontières d’échec compréhensibles
Un gestionnaire produisant plusieurs effets est difficile à reprendre lorsque la dernière étape échoue :
public function __invoke(OrderCompleted $message): void
{
$this->stock->decrease($message->orderId);
$this->invoice->create($message->orderId);
$this->mailer->sendConfirmation($message->orderId);
$this->crm->notify($message->orderId);
}Ici, $this->stock est un service agissant sur toute la commande, contrairement à l’entité de stock précédente : l’argument est un identifiant de commande. Si le CRM échoue après les opérations réussies de stock, de facturation et d’e-mail, une nouvelle tentative peut reprendre les quatre étapes.
Une possibilité consiste à valider les modifications locales nécessaires avec les demandes d’opérations ultérieures, puis à traiter ces dernières indépendamment. Cela peut rester dans une seule application. Chaque étape a encore besoin de sa propre identité, d’une gestion des doublons et de dépendances explicites lorsque l’ordre compte. Le découpage en classes ne suffit pas à sécuriser le traitement.
Les tentatives doivent être bornées et adaptées à l’erreur. Une indisponibilité temporaire peut justifier un nouvel essai ; des données invalides ou une autorisation refusée demandent généralement une correction. Un résultat externe incertain nécessite un rapprochement. Il faut vérifier la version de Messenger et le traitement des exceptions : tous les mécanismes de reprise ne respectent pas nécessairement la même limite d’essais.
S’il est configuré, un transport d’échec conserve les messages selon la politique applicable. Il ne faut pas supposer que cette destination existe automatiquement. Une reprise manuelle exécute de nouveau le gestionnaire, au lieu de continuer à la ligne en échec. Elle exige de déterminer quels effets précédents ont déjà eu lieu et de conserver l’identifiant de l’opération métier.
Diagnostiquer une opération entre plusieurs systèmes
Le point de départ est l’identifiant d’opération, pas seulement l’exception. Il permet de rapprocher les exécutions de l’état validé en base, des références du prestataire et de l’historique disponible du transport. Avant de relancer, il faut établir ce qui a abouti.
Les champs utiles des journaux structurés comprennent le type de message, l’identifiant d’opération métier, celui de l’entité concernée, les métadonnées de tentative disponibles, l’identité du processus, le début et la fin du traitement, le résultat de transaction et la référence externe. Le compteur de tentatives de Messenger ne couvre pas nécessairement toutes les remises du courtier ni toutes les exécutions manuelles. Les détails de validation, d’annulation et d’acquittement nécessitent une instrumentation adaptée ; ils ne figurent pas automatiquement dans chaque journal.
Il faut aussi examiner les redémarrages, limites de mémoire, versions exécutées en parallèle pendant un déploiement et délais du transport. Un délai de remise à disposition inférieur à la durée du traitement peut permettre des exécutions simultanées sur les transports qui l’utilisent. Un arrêt propre des processus et les options keepalive prises en charge peuvent réduire ce risque, mais ne remplacent pas la gestion des doublons. Ces options sont à vérifier pour la version et le transport utilisés.
Les journaux ne doivent pas contenir d’entités complètes, de contenu intégral des messages, d’identifiants secrets, de données de paiement complètes ni de données personnelles superflues.
Tester la répétition et la réussite partielle
Cet extrait Pest exécute une opération deux fois de suite. La préparation omise doit définir $orderId, $operationId, $handler et $invoiceRepository dans la fonction de test, avec une commande connue et une base isolée. La classe de message et la méthode du dépôt sont propres au projet, pas des fonctions fournies par Pest. Les arguments nommés demandent PHP 8 ou ultérieur ; la version de Pest installée a ses propres prérequis.
it(
'ne crée pas de seconde facture lors de la reprise',
function (): void {
// Préparation des données et des dépendances omise.
$message = new GenerateInvoice(
orderId: $orderId,
operationId: $operationId,
);
$handler($message);
$handler($message);
$invoices = $invoiceRepository->findByOrderId($orderId);
expect($invoices)->toHaveCount(1);
},
);Le test vérifie la présence d’une seule facture dans le cas séquentiel examiné. Il ne teste ni l’acquittement du transport, ni deux transactions simultanées, ni le comportement réel du prestataire. L’environnement de test doit écrire les changements conformément au contrat du gestionnaire et interroger la base, pas simplement une collection en mémoire.
D’autres tests ciblés devraient couvrir :
- Deux processus utilisant des connexions de base séparées et le même identifiant d’opération, y compris le conflit d’unicité et l’annulation de transaction.
- Une doublure de test du prestataire qui accepte l’opération, suivie d’un échec local. La reprise doit conserver la clé et emprunter le parcours prévu de consultation ou de déduplication.
- Un résultat ambigu après dépassement du délai et une reprise manuelle avec l’identité d’opération d’origine.
- La véritable règle métier : une facture, une diminution de stock voulue ou une opération de paiement autorisée.
Une doublure de test vérifie le protocole de l’application, pas les garanties du prestataire. Des tests de contrat dans un environnement de test adapté du prestataire peuvent vérifier les hypothèses d’intégration, sans prouver une livraison exactement une fois.
L’objectif utile est un résultat métier sûr malgré les répétitions. Cela repose sur l’identité de l’opération, les invariants locaux et la possibilité de clarifier les résultats externes incertains. Un message traité deux fois ne devrait pas se traduire par un double débit.
Références techniques
- Symfony Messenger 7.4 : transports, nouvelles tentatives et middlewares transactionnels — https://symfony.com/doc/7.4/messenger.html
- Doctrine ORM : transactions et concurrence — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/transactions-and-concurrency.html
- AWS : outbox transactionnelle, enregistrement atomique et publications répétées — https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- Stripe : exemple de règles d’idempotence propres à un prestataire, pas un contrat API universel — https://docs.stripe.com/api/idempotent_requests
