AnnonceNouveautés13 lectures
Un client devenait visible une fois qu’il n’avait plus besoin d’aide
LeTock.fréditeur 10 pts ·
Publié le 3 octobre 2026.
Un déclenchement manuel gelait la tâche pour toujours.
Signalé sur le forum par un client, sur la tâche qui déclenche sa file d’e-mails transactionnels toutes les dix minutes. Appel à POST /taches/{id}/declencher à 07:44, réponse « declenche: true » — puis plus rien, ni à 07:44 ni à 07:50. À 07:59 la tâche affichait encore une prochaine exécution à 07:44:12, dans le passé, et n’avait plus tourné depuis 07:31. Et l’historique ne portait rien : ni « périmée », ni « chevauche », ni échec. Une tâche critique arrêtée en silence, en se présentant comme active — la pire panne qu’un ordonnanceur puisse avoir, parce qu’elle ne se signale pas. La cause : trois chiffres de trop. La prise d’une occurrence avance l’échéance par comparaison-échange — « avance-la, mais seulement si elle vaut encore ce que je viens de lire » — et c’est elle qui empêche deux workers de prendre la même. Or une colonne timestamptz garde la microseconde, quand un Date JavaScript ne compte qu’en millisecondes : le worker lisait 07:44:12.345678, renvoyait 07:44:12.345, et la comparaison ne correspondait plus à rien. À chaque passage, indéfiniment. Mesuré sur la base de production, cinq essais sur cinq. Pourquoi personne ne l’avait vu : une échéance de cron tombe sur la seconde ronde, donc elle fait l’aller-retour intacte. Deux chemins seulement écrivent un instant à la microseconde, et tous deux en passant par now() en SQL : le déclenchement manuel, et le réveil d’une tâche enchaînée. Le second n’avait jamais été rencontré — aucune tâche enchaînée n’existe encore, et les 36 938 exécutions enregistrées sont toutes d’origine « planning ». Autrement dit l’enchaînement ne fonctionnait pas du tout, et le premier client à s’en servir aurait trouvé ses tâches gelées sans explication. Un test le prouve : il échouait avant le correctif. Le correctif est dans la colonne, pas dans les appelants. Arrondir dans les deux requêtes fautives, c’était deux corrections au lieu d’une et le piège laissé en place pour le troisième appelant, qui l’ignorerait comme les deux premiers. Une colonne dont la précision dépasse ce que l’application sait représenter est un piège pour tout écrivain : l’échéance est donc ramenée à la milliseconde, Postgres arrondit lui-même quel que soit le chemin, et l’aller-retour devient exact par construction. Et le même appel mentait sur son succès : declencher répondait « declenche: true » sans vérifier que l’écriture avait touché une ligne. Les gardes sur le mode et la pause sont faites avant, mais rien ne garantissait que l’état n’avait pas changé entre les deux — une tâche mise en pause dans l’intervalle rendait un succès pour un déclenchement qui n’avait pas eu lieu. La route répond désormais d’après ce que l’écriture a réellement fait, et rend un 409 si la tâche n’est plus exécutable.
La centième conversation était la dernière du monde.
La liste en chargeait cent, et rien après : pas de bouton, pas de message, pas un mot. La boîte affirmait simplement qu’il n’y avait plus rien — et pour une boîte de courrier c’est la pire faute possible, parce qu’on ne cherche pas ce qu’on ne sait pas manquant. Elle se parcourt désormais entièrement, et le repère est une date, pas un numéro de page : entre deux pages un message arrive et décale tout, si bien qu’un numéro ferait revoir le dernier fil de la page précédente ou en sauterait un. Le piège, et il était vicieux : une épingle flotte en tête. Si elle flottait sur chaque page, un fil épinglé très ancien fixerait le repère à sa propre date — la page suivante serait identique à la précédente, pour toujours. L’épingle ne vaut donc que pour la première page, et un test parcourt la boîte entière pour vérifier qu’aucun fil n’est sauté ni répété. Et ce qui ne peut pas être juste est dit au lieu d’être caché : les compteurs d’état portent sur les conversations chargées, et l’écran l’écrit. L’état se déduit en TypeScript — le réécrire en SQL pour compter juste ferait deux écritures de la même règle, donc deux règles à terme. C’était déjà le cas avant, sans le dire. Au passage : la liste de la boîte n’avait aucun test. Elle en a treize.
La boîte savait tout afficher et ne rien retirer.
On pouvait lire, chercher, ranger, répondre — et pas sortir un message de la boîte. Constaté de la pire façon : un destinataire de notre prospection a écrit deux fois, furieux, dont « la prochaine c’est plainte à la gendarmerie ». On lui a répondu, on l’a retiré de nos bases — et ses deux messages sont restés là, définitivement. « Traité » range un fil ; il n’en retire aucun. Arrivent donc la corbeille, l’épingle, le retour en non-lu et la sélection multiple. La corbeille date au lieu d’effacer : trente jours pour se raviser, parce que la moitié des suppressions sont des erreurs et qu’on ne s’en aperçoit pas dans la seconde — et l’échéance est annoncée sur la ligne, « part définitivement dans 3 jours », parce qu’une information vaut mieux qu’un incident. Le courrier n’est pas touché : c’est l’affichage qui cache, pas la donnée qui part, sinon le taux de remise d’hier se mettrait à mentir. La sélection multiple n’est pas un confort : trente réponses à une campagne se traitent d’un geste ou ne se traitent pas, et une boîte qu’on ne range plus redevient un tas. Le piège évité, et il était gros : écarter les fils jetés après la requête était trois lignes de moins à écrire. Le LIMIT compte alors avant le filtre — un domaine dont les cent derniers fils sont à la corbeille affichait une boîte vide, sans rien dire, et le défaut ne se serait vu qu’au bout d’un mois d’usage. Un test le garde. Au clavier : x cocher, e traité, u non lu, # jeter, comme ailleurs.
Regarder un message le faisait disparaître de la liste des messages à traiter.
Un destinataire de la campagne a répondu « De quoi tu te mêles », puis une minute plus tard « La prochaine c’est plainte à la gendarmerie ». Ces deux messages n’ont jamais figuré dans la liste de ce qui attend une réponse — et pendant ce temps, une relance automatique restait programmée vers ce domaine. Elle a été annulée, les adresses retirées, un mot d’excuse envoyé. Le défaut n’était pas dans la boîte, qui les montrait correctement : il était dans le bloc d’alerte publié la veille, qui filtrait sur « lu ». Quelqu’un avait ouvert l’écran, donc ils étaient lus, donc ils disparaissaient. Et le correctif a été de supprimer ce que j’avais écrit, pas d’en ajouter : j’étais parti poser une colonne « traité », une migration et une fonction, quand la boîte calcule déjà l’état « à répondre » — « le dernier mot est à eux, et personne n’a répondu », déduit à chaque lecture, donc impossible à périmer. Le bloc lit maintenant cet état. C’est la troisième fois cette semaine qu’une deuxième définition d’un mot déjà défini cause un défaut, après le drapeau « automatique » et le classement des refus. La leçon n’est pas d’être plus prudent, c’est de chercher si la réponse existe avant de l’écrire.
On subissait une protection de cadence qu’il suffisait de ne pas déclencher.
L’hébergeur qui fermait sa porte à notre adresse ne tient pas une liste noire : il compte les connexions par adresse source, et il coupe quand c’est trop rapide. Nous envoyions en rafale vers un même serveur, et nous le payions. Une seconde entre deux conversations vers le même serveur suffit : les serveurs différents continuent d’avancer en parallèle, donc le débit global ne bouge pas — ce qui disparaît, c’est la rafale. Et mon propre test ne testait rien. J’avais lu le réglage à l’import du module, puis le test le changeait dans l’environnement après coup : il passait, sans rien mesurer. Le réglage est maintenant injecté comme le transporteur et le résolveur. Deux tests le tiennent : la pause existe entre deux messages vers le même hôte, et elle ne sérialise pas des hôtes différents — sans ce second, on aurait pu « corriger » en tuant le parallélisme qui fait tout le débit.
La file d’expédition triait cent mille lignes pour en remettre cinquante.
Le classement par équipe, posé le matin même, lisait toute la file éligible avant d’en prendre un lot. Plan mesuré à 681 messages : lecture des 681, tri, fenêtrage, second tri — 420 tampons pour cinquante remises. Le problème n’est pas cette milliseconde, c’est sa pente : le travail suivait la taille de la file, pas celle du lot. À cent mille messages en attente, c’est un tri de cent mille lignes toutes les minutes. On parcourt donc les équipes — table petite et bornée — en ne demandant à chacune que ce qu’un lot peut contenir : la jointure s’arrête après son LIMIT, servie par l’index. 420 tampons → 36, et le coût suit désormais le nombre d’équipes, plus la profondeur de la file. L’index précédent est retiré : il commençait par la mauvaise colonne, et le garder n’aurait coûté que des écritures.
Vingt-deux pour cent de la campagne écrivait à des boîtes mortes.
La collecte garde un domaine qui publie un MX ou un A. C’est nécessaire et ça ne suffit pas : un enregistrement DNS dit qu’un nom existe, pas qu’une machine répond derrière. Mesuré sur trois jours : 122 domaines distincts, un échec chacun — donc un état, pas un incident. Chaque tentative coûtait vingt secondes d’attente, une place dans la file, et un échec de plus dans les statistiques du domaine d’envoi. Vu d’en face, un expéditeur qui frappe en permanence à des portes qui n’existent plus ressemble exactement à ce qu’on cherche à ne pas être — et notre adresse sortait justement d’un classement chez Cloudmark. La mesure ouvre donc une prise vers le serveur de courrier avant d’écrire, six secondes au plus, sans EHLO : on écoute la bannière et on raccroche. Et ce garde a failli être pire que le mal. Essai sur douze prospects réels juste avant publication : six injoignables, et les six chez le même hébergeur. Vérification : la connexion vers ses serveurs expire depuis notre machine alors que Google répond immédiatement — et que ce même hébergeur a accepté 1 267 de nos messages en trois jours. C’est donc un blocage temporaire de notre adresse, pas douze boîtes mortes. Tel quel, le garde aurait écarté en silence tous les prospects d’un hébergeur qui tient une grande part du « .fr », et la campagne aurait maigri sans que personne sache pourquoi. Le passage compte donc les échecs par serveur : quand un seul en concentre trois ou plus, il le dit en toutes lettres — « c’est notre adresse qui est bloquée là-bas, pas ces domaines qui sont morts ». Un domaine muet n’est pas condamné — il retourne à la collecte et sera retenté un autre jour, parce qu’un serveur peut être en maintenance. Et le test a corrigé le correctif : j’avais écrit qu’un domaine sans MX était injoignable. C’est faux — la RFC 5321 dit de retomber sur son enregistrement A, et beaucoup de petits domaines sont dans ce cas. La « correction » aurait retiré de la campagne des gens parfaitement joignables.
Aucun site n’attend plus à cause d’un autre.
La correction précédente faisait passer le courrier attendu devant le froid — utile, et insuffisant. Il restait une seule file pour toute la plateforme, servie dans l’ordre : un client qui dépose cinq cents messages retardait tous les autres, y compris quelqu’un qui n’en envoyait qu’un. Mesuré le même jour : 553 messages d’un seul domaine devant deux messages de deux autres, qui attendaient une demi-heure pour une file qui ne leur appartenait pas. Le rang est maintenant calculé par équipe : le premier message de chacune part avant le deuxième de n’importe laquelle. Un gros expéditeur n’est pas ralenti — il prend toute la place qui reste, et il y en a presque toujours — mais il ne prend plus celle des autres. C’est la seule façon d’affirmer « aucun site n’attend à cause d’un autre » sans avoir à le surveiller. Le détail qui aurait coûté cher : le classement par équipe passe par une fonction de fenêtrage, qui ne supporte pas le verrouillage de lignes — la garde contre le double envoi a donc dû être replacée sur la mise à jour elle-même. Deux workers peuvent choisir les mêmes messages, mais le second se bloque sur la ligne puis réévalue sa condition, et « en attente » est alors faux. Sans ça, deux workers remettaient deux fois le même message : une faute irréversible, qui coûte la réputation du domaine du client.
Un message à un nouvel inscrit était cinq cent trente-septième dans la file.
Constaté en écrivant à quelqu’un qui venait de s’inscrire : le message n’est pas parti. 537 messages frais attendaient devant, déposés en une fois par la campagne de prospection du matin. À trente-cinq remises par tour, cela faisait une demi-heure d’attente pour un message qu’on attend dans la minute. La file était déjà servie « le plus neuf d’abord, les reprises ensuite » — corrigé la veille — mais tous les messages neufs restaient à égalité. Or un message froid n’a pas d’échéance ; un code de connexion, si. C’était le premier point d’un client, qui l’avait dit avant qu’on le mesure, avec un cas pire : six minutes trente pour un lien de mot de passe oublié. La campagne marque désormais ce qu’elle envoie : elle sait qu’elle écrit du courrier froid au moment où elle l’écrit, et le déduire après coup reviendrait à redemander une information qu’on avait déjà — en la payant sur le chemin le plus chaud du produit. Le débit du froid ne change pas pour autant : il est servi après, et il n’y a rien d’autre à servir la plupart du temps. Ce qui change est la latence du courrier attendu, la seule qui se remarque.
La première liste annonçait sept messages sans réponse. Six étaient des machines.
Corrigé une heure après l’avoir publié, en allant répondre aux sept. Réponses d’absence, accusés de réception, avis de non-remise : une seule personne attendait vraiment. Une alerte qui crie sept fois pour une vraie demande apprend à ne plus l’ouvrir — c’est précisément ce que ce bloc existe pour éviter, et il l’a fait dès sa première journée. La faute tient en un réflexe : j’avais jugé sur le sujet, « faute de mieux ». Il y avait mieux, deux fichiers plus loin : la réception pose déjà un drapeau « automatique » d’après les en-têtes que l’expéditeur déclare lui-même — Auto-Submitted (RFC 3834), Precedence: bulk, List-Unsubscribe. On ne devine pas, on lit une déclaration. Le filtre s’y appuie maintenant, et le tri par sujet ne reste qu’en dernier recours pour les rares expéditeurs qui n’annoncent rien.
Un client devenait visible une fois qu’il n’avait plus besoin d’aide.
Deux choses manquaient, et c’est la même : qu’est-ce qui attend un humain ? Un nouvel inscrit a déclaré et prouvé un domaine ; il n’apparaissait nulle part sur l’écran, parce que le tableau par domaine ne liste que ceux qui ont déjà envoyé du courrier. Et deux personnes avaient répondu à notre prospection — dont une pour dire que notre message était incompréhensible — sans que nous le sachions avant d’aller regarder la base, par hasard, plusieurs heures après. Un message sans réponse n’est pas un détail d’organisation : c’est quelqu’un qui a pris du temps pour nous. L’écran porte désormais « en attente de nous », et une alerte part au plus toutes les huit heures quand un message dort depuis plus de quatre. Trois choix qui comptent : les avis de non-remise et les réponses automatiques ne sont pas comptés — personne ne les a écrits, et les compter ferait un chiffre toujours rouge, donc un chiffre qu’on cesse de regarder ; une inscription ne réveille personne, elle mérite d’être vue, pas qu’on sonne ; et le résumé nomme le plus ancien et depuis quand, parce que « 3 en attente » ne dit pas s’il s’agit de trois minutes ou de trois jours.
Et trois manques d’API comblés, tous signalés par le même client.
« en_pause » à la création était accepté puis jeté : l’appel rendait 201 et la tâche démarrait. Un champ accepté puis ignoré est pire qu’un champ refusé — le refus s’apprend tout de suite, l’oubli se découvre quand la tâche a tourné. Elle naît maintenant en pause et sans échéance, sans quoi elle partirait avec un retard déjà constitué le jour où on la reprend. GET /mails/{id} ne rendait pas le contenu : on savait que le serveur distant avait accepté, pas ce qu’il avait accepté — vérifier après coup un lien de mot de passe oublié obligeait à garder une copie de son côté, c’est-à-dire à ne pas nous faire confiance. Le texte et le HTML sont rendus, et un corps effacé après quatre-vingt-dix jours le dit au lieu d’être vide. Les événements se filtrent enfin par message et par adresse, au lieu de relire toute la chronologie pour en garder trois lignes.
Un détail vous a gêné, ou vous a coûté du temps ? Répondez ici plutôt
que par courrier : la réponse servira aussi aux suivants.