N'envoyez jamais un courriel depuis une requête Web
L'affirmation Tout appel à un service tiers à l'intérieur d'un gestionnaire de requête fait de ce service une dépendance dure de votre application. Envoyer un courriel de façon syn...
L'affirmation
Tout appel à un service tiers à l'intérieur d'un gestionnaire de requête fait de ce service une dépendance dure de votre application. Envoyer un courriel de façon synchrone signifie que votre page de confirmation de commande hérite de la disponibilité de votre fournisseur de messagerie multipliée par celle de votre propre pile, et qu'une commande peut être perdue parce qu'une connexion SMTP a expiré après un paiement réussi.
Ce qui déraille réellement
Prenez le contrôleur habituel : débiter la carte, écrire la commande, envoyer la confirmation, afficher la page. Si l'appel de messagerie se bloque 30 secondes :
- La cliente fixe un indicateur d'attente et recharge, produisant parfois un second débit.
- Un de vos processus de travail reste occupé à ne rien faire pendant 30 secondes. À 8 travailleurs, 20 paiements simultanés épuisent le bassin et tout le site cesse de répondre.
- Si la requête expire après le débit et avant la validation, vous avez encaissé pour une commande qui n'existe pas.
Le dernier cas n'a rien d'hypothétique. C'est le bogue sérieux le plus fréquent que nous trouvions dans les petites applications de commerce, et il découle entièrement de l'ordre des opérations.
Le bon ordre
Validez d'abord le changement d'état, mettez ensuite l'effet de bord en file, puis répondez immédiatement.
BEGIN;
INSERT INTO orders (...) VALUES (...) RETURNING id;
INSERT INTO outbox (topic, payload, run_after)
VALUES ('order.confirmation', '{"order_id": 4821}', now());
COMMIT;
Écrire la tâche dans la même transaction que la commande, c'est tout l'enjeu. Si la transaction est annulée, le courriel n'est pas mis en file. Si elle est validée, le courriel est garanti d'y être. Publier vers un courtier externe avant la validation produit l'inverse : des courriels au sujet de commandes jamais créées.
Un travailleur interroge la table :
SELECT * FROM outbox
WHERE processed_at IS NULL AND run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 20;
FOR UPDATE SKIP LOCKED rend l'opération sûre avec plusieurs travailleurs : chacun réclame des lignes que personne d'autre ne détient, sans coordination et sans serveur de file distinct. En deçà de quelques centaines de tâches par seconde, Postgres constitue une excellente file d'attente et une chose de moins à exploiter.
Les reprises exigent un calendrier et un plafond
Les fournisseurs de messagerie renvoient couramment des échecs passagers. Reprenez avec un délai exponentiel et arrêtez-vous à un moment donné :
tentative 1 : immediate
tentative 2 : +1 min
tentative 3 : +5 min
tentative 4 : +25 min
tentative 5 : +2 h
ensuite : file d'echec, alerter une personne
L'étape de file d'échec n'est pas facultative. Une tâche qui se reprend indéfiniment, c'est ainsi qu'on découvre trois semaines plus tard qu'une seule adresse mal formée génère 40 000 tentatives ratées par jour.
« Au moins une fois » signifie des doublons
Toute file qui survit à une panne livrera parfois une tâche deux fois : le travailleur a envoyé le courriel puis est mort avant de marquer la ligne traitée. Concevez en conséquence plutôt que de chercher à l'empêcher. Transmettez une clé d'idempotence au fournisseur :
X-Entity-Ref-ID: order-4821-confirmation
La plupart des interfaces de messagerie transactionnelle dédoublonnent sur cet en-tête dans une fenêtre de temps. Pour les tâches qui touchent vos propres systèmes, rendez le gestionnaire idempotent : vérifiez si l'état que vous vous apprêtez à poser l'est déjà et terminez avec succès le cas échéant.
Ce qui appartient aussi à la file
Le même raisonnement vaut pour toute opération lente ou peu fiable déclenchée par une action d'utilisateur : génération de PDF et de factures, traitement d'images, livraison de rappels HTTP vers le point d'accès d'un client, synchronisation vers une GRC, transmission d'une commande à un transporteur. Si cela appelle quelque chose que vous n'exploitez pas, ou prend plus de 200 ms environ, cela va derrière la file.
La seule chose à surveiller
La profondeur de la file ne suffit pas : 400 tâches en attente, c'est acceptable si elles s'écoulent. Alertez sur l'âge de la plus ancienne tâche non traitée :
SELECT extract(epoch from now() - min(run_after))
FROM outbox WHERE processed_at IS NULL;
Au-delà de dix minutes, vos travailleurs sont arrêtés, et la défaillance est autrement totalement silencieuse : le site fonctionne, les commandes rentrent, et aucune confirmation n'est partie depuis le dîner.