
Table des matières
Ouvrir table des matières
Tout était vert
Laissez-moi vous décrire la situation, parce qu’elle va vous sembler familière. Un moteur de regroupement d’envois — celui-là même dont je vous ai raconté la garde d’admission — construit des archives tar.gz de N documents et les dépose chez un système tiers. Côté qualité, du sérieux : une bonne centaine de tests unitaires sur la machine à états, les déclencheurs, les reprises après crash. Des tests E2E qui font tourner le moteur réel contre un vrai PostgreSQL, un vrai stockage, un vrai serveur SFTP local. Tout est vert. On dort tranquille.
Eh bien dans ce système vert de chez vert dormaient deux bombes : un interblocage capable de geler définitivement la boucle d’envoi dès la première activation réelle, et une hypothèse de format qui aurait fait rejeter en bloc chaque lot de production. Aucun des deux filets ne pouvait les voir. Ni l’un, ni l’autre, ni les deux ensemble.
Cet article raconte comment on les a trouvées quand même — et les deux filets supplémentaires qu’il a fallu tendre : une campagne de charge à taille réelle, et des sondes empiriques contre le système du tiers. Parce que c’est ma conviction depuis ce chantier : une suite de tests prouve le comportement de votre code. Elle ne prouve ni son échelle, ni la réalité du monde extérieur.
Bombe n°1 : l’interblocage qui dormait sous les 16 Kio
Commençons par la plus sournoise. Pour construire l’archive d’un lot, le code faisait quelque chose de très raisonnable en apparence : ajouter les fichiers un par un au flux tar, en attendant la confirmation de chacun avant d’ajouter le suivant, puis brancher la compression gzip à la fin.
// AVANT — le code qui dort tranquille… sous 16 Kio.
async function buildArchive(files: FileEntry[]): Promise<Buffer> {
const pack = tar.pack();
for (const f of files) {
await addEntry(pack, f.name, f.content); // attend le callback de pack.entry()
}
pack.finalize();
return compress(pack); // le consommateur gzip n'est branché qu'ICI
}Vous voyez le bug ? Moi non plus, pendant longtemps. Il faut connaître la mécanique de backpressure des streams Node : pack.entry() ne rappelle son callback qu’une fois ses octets drainés vers l’aval. Tant qu’aucun consommateur n’est branché, les octets s’accumulent dans le tampon interne du flux. Et ce tampon a une taille par défaut — le fameux highWaterMark, 16 Kio. Dès que le cumul des entrées dépasse ce seuil, le callback suivant attend un drainage… qui ne viendra jamais, puisque le consommateur n’est branché qu’après la boucle. Interblocage. Le code s’arrête, sans erreur, pour toujours.
Maintenant, la question intéressante : pourquoi aucun test ne le voyait ?
- Les tests unitaires construisaient des archives d’un petit fichier. Quelques centaines d’octets : jamais près du seuil.
- Les E2E faisaient des lots de 3 documents. Sous les 16 Kio cumulés — par chance.
- Or les lots réels visés se comptent en centaines, voire milliers de documents. Tous auraient franchi le seuil. Autrement dit : le bug était garanti de se déclencher en production, et garanti de ne se déclencher que là.
Et le pire est ailleurs. Ce gel aurait été parfaitement silencieux : le tick d’envoi se fige, et comme la politique de chevauchement du planificateur est « SKIP » (si le tick précédent tourne encore, on saute le suivant), il n’y a ni empilement, ni timeout, ni erreur. Juste un lot gelé, un planificateur qui ne fait plus rien, et des dashboards paisibles. Le silence des pannes qu’on n’a pas instrumentées, c’est le vrai cauchemar de l’exploitation.
Le correctif tient en trois lignes de réorganisation : brancher le consommateur gzip et créer la promesse de compression avant la première entrée.
// APRÈS — le consommateur est branché AVANT d'écrire la moindre entrée.
async function buildArchive(files: FileEntry[]): Promise<Buffer> {
const pack = tar.pack();
const compressed = compress(pack); // brancher l'aval D'ABORD
for (const f of files) {
await addEntry(pack, f.name, f.content); // draine en continu, ne bloque plus
}
pack.finalize();
return compressed;
}Et surtout, la régression est épinglée par un test volontairement calibré : 50 membres de 4 Kio — plus de 16 Kio cumulés, très au-dessus du seuil — qui vérifie l’aller-retour complet de l’archive. Sur le code d’avant, ce test se bloque et expire. C’est la leçon que je vous laisse en passant : les paramètres par défaut de vos runtimes (highWaterMark, tailles de pool, limites de payload…) sont des seuils de bug. Écrivez au moins un test qui les franchit exprès.
Filet n°3 : la campagne de charge (mesurer le vrai code, pas une copie)
Ce deadlock n’a pas été trouvé en relisant le code. Il a été trouvé parce qu’on a enfin fait ce qui n’avait jamais été fait : pousser des lots pleins, à taille réelle, à travers le moteur réel. Une campagne de charge, avec un harnais dédié : un run = un lot plein d’une famille de flux, de l’admission jusqu’à la finalisation, sur une pile locale réelle (PostgreSQL, banc SFTP).
Le détail dont je suis le plus content, c’est la méthode d’instrumentation. Le réflexe habituel, c’est d’ajouter des sondes dans le code pour mesurer. Mauvaise idée : vous mesurez alors un chemin de code qui n’existe pas en production. Ici, toute l’instrumentation passe par la décoration des liaisons d’injection de dépendances, côté harnais : la fabrique d’archive, la fabrique SFTP, le registre de finalisation sont enveloppés de chronomètres au moment du câblage — et le code de production n’est pas modifié d’une ligne. Si vous avez une archi avec un conteneur DI digne de ce nom (encore un point pour l’architecture hexagonale), ce pattern est un bijou : le harnais observe, le moteur ignore qu’il est observé.
Qu’est-ce qu’on y a appris, au-delà du deadlock ?
- Où est le plafond de débit : la transaction d’admission sérialise sur le verrou
FOR UPDATEde la ligne de lot ouverte. C’est par construction le goulot — le mesurer donne la capacité réelle de remplissage, et la certitude que la contention se comporte comme conçu (elle attend, elle n’échoue pas). - Une règle de dimensionnement mémoire : l’envoi d’un lot tient en mémoire les charges gelées, le flux tar et la sortie gzip. La mesure au plafond de taille a donné une règle simple : prévoir pour le worker de l’ordre de 4× le plafond de taille d’archive configuré, plus un socle. Une règle écrite noire sur blanc dans la doc d’architecture — pas un folklore oral.
- Des seuils d’alerte dérivés des mesures : tick d’envoi plus lent que sa cadence, mémoire au-delà de la règle, latence d’admission au-delà de quelques fois le pire cas mesuré. Des tripwires d’ordre de grandeur, pas des SLA — mais des tripwires issus de chiffres, pas d’intuitions. Un seuil d’alerte deviné, c’est soit du bruit, soit du silence ; les deux endorment.
Filet n°4 : les sondes A/B/C, ou l’art d’interroger le système réel
Reste la seconde bombe, et elle ne se cachait pas dans mon code : elle se cachait dans ce que la spécification du tiers ne dit pas.
Deux hypothèses de conception étaient honnêtement marquées « non vérifiées contre la source » dans le dossier d’architecture. Un : comment disposer les fichiers à l’intérieur d’une archive à plusieurs documents — la spec dit « archive tar.gz », point. À plat ? Sous un dossier ? Mystère. Deux : sur quoi porte le contrôle d’unicité des envois côté tiers — l’identifiant, ou le contenu ? Toute la doctrine de rejeu en dépendait.
Vous connaissez ma marotte : on n’énonce jamais une règle qu’on ne peut pas citer. Quand la spec est muette, il ne reste qu’une sortie honnête : l’expérience contre le système réel. Avec une discipline : le prérequis a été écrit dans le dossier d’architecture avant — pas d’activation du regroupement tant qu’un vrai lot multi-documents n’a pas été accepté par l’environnement de qualification du tiers. Puis trois sondes, déposées pour de vrai :
- Sonde A — archive à deux entrées plates, à la racine : acceptée.
- Sonde B — la forme alors livrée : entrées imbriquées sous un dossier portant le nom de l’envoi (forme calquée, de bonne foi, sur le seul précédent observé) : rejetée, motif « type de fichier ». Verdict : le tiers évalue les noms d’entrées de l’archive un par un, et une entrée de type dossier échoue ses contrôles. Si on avait activé sans sonder, chaque lot de production aurait été rejeté en bloc dès le premier jour.
- Sonde C — une archive octet pour octet identique à A, redéposée sous un identifiant neuf : rejetée pour non-unicité. Verdict : l’unicité est indexée sur le contenu, pas sur l’identifiant. Toute la stratégie de rejeu s’est recalée sur ce fait-là (au rejeu, ce sont les octets reconstruits qui diffèrent — subtilité épinglée par test, elle aussi).
Le correctif de la sonde B a été appliqué le jour même : archive plate, quel que soit le nombre de documents. Et j’en retire un aphorisme que je vous laisse méditer : la permission n’est pas l’acceptation. Une variante que la spec autorisait explicitement était, dans les faits, silencieusement ignorée par le système d’en face. Le papier vous donne des droits ; seul le système réel vous donne des certitudes.

Épilogue : quatre filets, pas deux
Récapitulons le tableau de chasse : un interblocage garanti en production et invisible en test, trouvé par la campagne de charge ; une hypothèse de format fatale et indécidable sur le papier, tranchée par des sondes contre le système réel. Zéro des deux par la pyramide de tests classique — non pas qu’elle soit mauvaise, mais parce que chaque filet n’attrape que ce pour quoi il est tendu :
- les tests unitaires prouvent la logique ;
- les E2E prouvent l’intégration ;
- la campagne de charge prouve l’échelle — et débusque tout ce qui dort sous les seuils (backpressure, mémoire, verrous) ;
- les sondes empiriques prouvent le monde extérieur — tout ce que la spec ne dit pas, ou dit sans que le système d’en face ne l’honore.
Les deux premiers, tout le monde les a. Les deux derniers se construisent — un harnais qui décore le câblage DI sans toucher au code, un protocole de sondes avec prérequis bloquant écrit à l’avance. Rien de bien sorcier, encore une fois. Juste des filets différents pour des poissons différents.
Pour creuser, je vous renvoie vers les tests unitaires (le filet n°1 reste indispensable), le TDD, et la petite famille d’articles dont celui-ci est le troisième épisode : le quota arbitré en base et le batcher incapable d’être en retard.
Et vous — quel est le bug le plus sournois que vos tests verts ont laissé passer ? Racontez-le-moi en commentaire, les histoires de deadlock silencieux sont mes préférées. Je me ferai une joie de vous répondre.

