Blog / Symfony

Messenger 8.2 : l’inbox ne rend pas vos handlers idempotents

L’inbox de Messenger 8.2 saute les handlers d’un message déjà traité. Elle protège votre base, pas vos e-mails ni vos appels d’API : ce qu’il reste à faire.

Par Romain Barberi 7 min de lecture 7 sources

Un worker Messenger enregistre une facture, valide sa transaction, puis meurt avant d’acquitter le message auprès du broker. Le broker le redélivre, le handler repasse, et le client reçoit deux factures. Le 26 octobre, le blog Symfony a présenté l’inbox de Messenger, qui arrive avec Symfony 8.2 en novembre : une table qui retient les messages déjà traités pour sauter leurs handlers à la redélivrance. C’est la moitié manquante de l’outbox présenté en septembre, et c’est une vraie avancée pour les applications qui ont quelques années de RabbitMQ ou de SQS derrière elles. Ma position est pourtant nette : l’inbox rend idempotente votre base de données, pas vos handlers. Tout ce qu’un handler fait hors de la transaction reste à traiter, et l’activer sur un legacy demande de vérifier trois points avant de cocher la case.

Le trou entre le commit et l’ack

Un broker livre « au moins une fois ». Entre le moment où le handler a fini son travail et celui où le worker acquitte le message, il existe toujours une fenêtre : un déploiement qui tue le conteneur, un OOM, une perte de connexion au broker. Si la fenêtre se ferme mal, le message revient.

Jusqu’ici, Messenger laissait ce problème à chaque handler. Dans les bases de code que j’ai reprises, la réponse prend en général trois formes : rien du tout (et des doublons rares, donc jamais diagnostiqués), un SELECT de vérification en tête de handler (qui ne résiste pas à deux workers concurrents), ou une contrainte d’unicité posée sur une table métier après un incident. Aucune n’est systématique, et c’est bien le problème : la garantie dépend de la discipline de la personne qui a écrit chaque handler.

Il ne faut pas confondre l’inbox avec le DeduplicateStamp arrivé en 7.3. Celui-ci évite d’envoyer deux fois le même message tant qu’un verrou est tenu, côté producteur. L’inbox agit côté consommateur, sur un message bien unique mais livré deux fois.

Ce que fait l’inbox, d’après le code de la branche 8.2

La configuration tient en trois lignes utiles, d’après le billet officiel :

# config/packages/messenger.yaml
framework:
    messenger:
        identity_stamps: true   # chaque message reçoit un MessageIdStamp

        transports:
            orders:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                inbox: true     # ou { connection: billing, retention: 604800 }

        buses:
            messenger.bus.default:
                middleware: [doctrine_transaction]

Le billet décrit le principe ; le code de DoctrineInbox et d’InboxMiddleware, fusionnés le 15 octobre dans la branche 8.2 (PR #66957), en dit davantage :

  • La table messenger_inbox a trois colonnes : consumer (le nom du transport), message_id et handled_at. La clé primaire porte sur le couple consumer, message_id. C’est elle qui fait tout le travail : le second INSERT du même message échoue sur la contrainte, et Messenger en conclut qu’il est déjà traité.
  • L’insertion se fait dans une transaction imbriquée, donc un savepoint, pour qu’un échec ne casse pas la transaction du handler sur PostgreSQL. Deux workers qui reçoivent le même message se sérialisent sur cette clé : le second attend que le premier valide ou annule.
  • Hors transaction, record() lève une LogicException. Sans le middleware doctrine_transaction sur le bus, rien ne fonctionne, et c’est voulu.
  • La purge est probabiliste : à chaque enregistrement, une chance sur mille de supprimer les lignes plus vieilles que la rétention (7 jours par défaut) pour ce transport. Sur un transport à faible trafic, la table peut donc garder des lignes bien au-delà de la rétention ; ce n’est pas grave, mais ne vous en étonnez pas.
  • identity_stamps est nouveau lui aussi en 8.2 : c’est FlowContextMiddleware qui pose un identifiant de 32 caractères hexadécimaux sur chaque message dispatché. Activer inbox sans identity_stamps fait échouer la compilation du conteneur.

Ce qu’elle ne protège pas : tout ce qui sort de la base

L’inbox garantit une chose précise : la ligne d’inbox et les écritures du handler sont validées ou annulées ensemble. Un e-mail, un appel à l’API de Stripe ou un fichier poussé sur un bucket ne font pas partie de cette transaction. Si le worker meurt après l’appel externe et avant le commit, la ligne d’inbox disparaît avec le rollback, et le retry rejoue l’appel.

Pour le montrer sans attendre la sortie de la 8.2, ce script reproduit le schéma et la logique d’enregistrement de DoctrineInbox avec PDO et SQLite :

<?php
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE messenger_inbox (consumer VARCHAR(190) NOT NULL, message_id VARCHAR(190) NOT NULL,
            handled_at DATETIME NOT NULL, PRIMARY KEY (consumer, message_id))');
$pdo->exec('CREATE TABLE invoice (order_id INT NOT NULL)');
$emails = 0; // effet de bord hors base : un e-mail, un appel HTTP

function consume(PDO $pdo, string $id, int $orderId, bool $crashBeforeCommit = false): string {
    global $emails;
    $pdo->beginTransaction();                    // middleware doctrine_transaction
    $pdo->exec('SAVEPOINT inbox');               // DoctrineInbox::record()
    try {
        $pdo->prepare('INSERT INTO messenger_inbox VALUES (?, ?, ?)')->execute(['orders', $id, date('c')]);
        $pdo->exec('RELEASE SAVEPOINT inbox');
    } catch (PDOException $e) {
        if ('23000' !== $e->getCode()) { throw $e; }
        $pdo->exec('ROLLBACK TO SAVEPOINT inbox');
        $pdo->commit();
        return "message $id : déjà traité, handlers sautés, ack";
    }
    $pdo->prepare('INSERT INTO invoice VALUES (?)')->execute([$orderId]); // le handler
    $emails++;                                                            // l'e-mail part
    if ($crashBeforeCommit) { $pdo->rollBack(); return "message $id : worker tué avant le commit"; }
    $pdo->commit();
    return "message $id : traité";
}

echo consume($pdo, 'a1', 42), "\n";        // première livraison
echo consume($pdo, 'a1', 42), "\n";        // redélivrance (crash entre commit et ack)
echo consume($pdo, 'b2', 43, true), "\n";  // crash avant le commit
echo consume($pdo, 'b2', 43), "\n";        // redélivrance : rejouée, à raison
printf("factures : %d, e-mails envoyés : %d\n", $pdo->query('SELECT COUNT(*) FROM invoice')->fetchColumn(), $emails);
message a1 : traité
message a1 : déjà traité, handlers sautés, ack
message b2 : worker tué avant le commit
message b2 : traité
factures : 2, e-mails envoyés : 3

Ce script a été exécuté sous PHP 8.3 avec SQLite. Le résultat dit exactement ce que fait l’inbox : deux factures, ce qui est juste, et trois e-mails, ce qui ne l’est pas. Le premier cas, celui du crash entre commit et ack, est réglé. Le second ne l’est pas et ne peut pas l’être par une table locale.

Pour les effets externes, les outils restent ceux d’avant : une clé d’idempotence transmise à l’API qui l’accepte (Stripe le fait), ou un e-mail qui n’est pas envoyé par le handler mais dispatché vers un transport doté d’un outbox, pour partir seulement après le commit. Dans les deux cas, l’identifiant du message que pose désormais identity_stamps fait une bonne clé.

Trois vérifications avant de l’activer sur un legacy

Le code de la 8.2 contient trois comportements qui ne se voient pas dans la configuration et qui touchent précisément les applications anciennes.

Les messages sans identifiant passent sans protection. InboxMiddleware traite un message sans MessageIdStamp normalement, avec un simple avertissement dans les logs. Or seuls les messages dispatchés par une application en 8.2 avec identity_stamps en portent un. Les messages déjà en file au moment du déploiement, ceux d’un producteur resté en 6.4 ou 7.4 et ceux d’un service écrit dans un autre langage arrivent sans. Pour ce dernier cas, la 8.2 ajoute un InteropSerializer et un IdentityStampHeaderMapper qui lisent un en-tête message_id : encore faut-il que le producteur l’envoie, et qu’il soit stable d’une livraison à l’autre.

DispatchAfterCurrentBusStamp devient une erreur. C’est le piège le plus coûteux. Beaucoup d’applications Symfony dispatchent leurs événements de domaine avec ce stamp, pour qu’ils partent après la fin du handler. Avec l’inbox, ce message partirait après le commit de la ligne d’inbox : si son envoi échoue, le retry saute le handler et le message est perdu. Messenger refuse donc, et lève une LogicException au moment du dispatch, sauf si le message va uniquement vers des transports dotés d’un outbox. Autrement dit, activer l’inbox sur un transport dont les handlers utilisent ce stamp fait échouer ces handlers en production, à chaque message qui passe par ce chemin. Avant d’activer quoi que ce soit, cherchez-le :

grep -rn "DispatchAfterCurrentBusStamp" src/

La base de l’inbox doit être celle des handlers. L’option connection désigne une connexion DBAL, et la garantie n’existe que si les écritures métier passent par la même. Un handler qui écrit dans deux bases, ou dans Elasticsearch, n’a la garantie que pour la connexion de l’inbox.

S’y ajoutent deux cas plus rares : les messages traités par un batch handler ne sont pas enregistrés, et ceux rejoués depuis le failure transport le sont sous le nom du transport d’origine, ce qui est le comportement qu’on attend.

Les limites

L’inbox ajoute une écriture et un index par message traité. Sur un transport à très fort débit, c’est une table chaude de plus dans la base principale ; pour des millions de messages par jour, je mesurerais avant d’activer, et j’envisagerais une connexion dédiée si les handlers concernés le permettent.

La rétention est un pari sur le comportement du broker. Si un message peut revenir plus de 7 jours après son traitement, par exemple après une panne longue ou un rejeu manuel d’une file entière, il sera traité de nouveau. Réglez retention sur le pire délai réaliste de votre infrastructure, pas sur le cas nominal.

Le contre-argument le plus sérieux est celui des puristes : un handler bien écrit est idempotent par construction, à partir de clés métier, et une table technique de messages traités masque le problème au lieu de le régler. Je pense qu’il a raison sur le fond et tort en pratique. Sur un legacy, l’idempotence métier se construit handler par handler, sur des mois ; l’inbox couvre tout le transport en une ligne pendant ce temps-là, et les deux ne s’excluent pas.

Cet article s’appuie sur le billet officiel, la pull request et le code de la branche 8.2 au 26 octobre ; Symfony 8.2 n’étant pas sortie, c’est la logique de DoctrineInbox qui a été reproduite plus haut, pas le composant lui-même.

Concrètement, par où commencer

  1. Listez vos transports consommés et, pour chacun, les handlers qui produisent un effet hors base : e-mail, SMS, appel HTTP, fichier. Ce sont eux que l’inbox ne couvre pas.
  2. Lancez le grep sur DispatchAfterCurrentBusStamp. Chaque occurrence dans un handler consommé doit soit perdre le stamp, soit viser un transport doté d’un outbox.
  3. Vérifiez que tous les producteurs d’un transport passeront en 8.2 avec identity_stamps avant de compter sur l’inbox, ou qu’ils envoient un en-tête message_id stable.
  4. Fixez la rétention à partir du pire délai de redélivrance observé, failure transport et rejeux manuels compris.
  5. Activez l’inbox sur un seul transport, de préférence celui de la facturation ou des paiements, et provoquez la panne en préproduction : tuez le worker juste après le commit et vérifiez dans les logs, au niveau info, la ligne « Skipping message » qu’écrit le middleware.

L’outbox de septembre sécurisait l’envoi, l’inbox sécurise la réception ; ce qui se passe entre un handler et le monde extérieur reste, pour longtemps encore, une affaire de conception plutôt que de configuration.

Sources

Sur ce sujet, l’offre de l’atelier : Migration Symfony

À lire aussi

Un socle à migrer, une architecture à fiabiliser ?

Renfort senior en régie, ou forfait à périmètre et prix fixés : l’atelier intervient à distance sur PHP, Symfony, les systèmes distribués et l’adoption des agents de code, avec des tests de caractérisation avant chaque migration.