Aller au contenu
gaetancottrez.dev

Ce que ni les tests unitaires ni les E2E ne voient : l'interblocage qui dormait sous les 16 Kio

Publié: at 08:00 | (9 min de lecture)
Ce que ni les tests unitaires ni les E2E ne voient : l'interblocage qui dormait sous les 16 Kio

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 ?

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 ?

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 :

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.

Les trois sondes A, B et C contre le système réel

É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 :

  1. les tests unitaires prouvent la logique ;
  2. les E2E prouvent l’intégration ;
  3. la campagne de charge prouve l’échelle — et débusque tout ce qui dort sous les seuils (backpressure, mémoire, verrous) ;
  4. 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.

Vous pourriez aussi aimer

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Article précédent
À qui est ce document ? Pourquoi on ne résout jamais un tenant depuis le contenu
Article suivant
Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard