Un registre de publications et de trades ne se réécrit jamais. Anatomie de trois défaillances réelles : les écritures perdues, la republication en double qu'elles fabriquent, et les autorisations qui n'autorisent rien parce qu'elles n'écrivent rien.
La plupart des fichiers d'une application décrivent un état : la configuration courante, le panier ouvert, les préférences. On les charge, on les modifie, on les réécrit. C'est le geste par défaut de tout développeur, et il est correct pour un état.
Deux de nos fichiers ne décrivent pas un état. Ils décrivent des faits passés : « ce produit a été publié à cette heure, sur ces canaux », « ce trade s'est clôturé à ce prix, à cette date ». Un fait ne se modifie pas. Il s'ajoute. La différence semble philosophique jusqu'au moment où on mesure ce que coûte l'avoir ignorée : quatre entrées survivantes sur vingt, des publications en double, et cinq mois de statistiques réécrites en silence.
Le registre de publication était un tableau JSON. Chaque producteur qui publiait quelque chose faisait le geste évident : lire le fichier, ajouter son entrée au tableau, réécrire le fichier entier. Trois opérations, aucune atomicité.
// L'anti-pattern, dans toute sa banalité const registre = JSON.parse(fs.readFileSync(CHEMIN)); // (1) lecture registre.push({ type, at: Date.now(), canaux }); // (2) modification fs.writeFileSync(CHEMIN, JSON.stringify(registre)); // (3) réécriture TOTALE
Deux producteurs qui terminent à quelques dizaines de millisecondes d'écart lisent tous les deux la même version, ajoutent chacun leur entrée à leur copie en mémoire, et réécrivent tous les deux le fichier complet. Le second écrase le premier. L'entrée du premier n'existe plus, et rien ne signale sa disparition : le fichier est valide, bien formé, simplement incomplet.
Mesuré chez nous, sur une soirée où plusieurs produits se publiaient en parallèle : quatre entrées sur vingt survivaient. Quatre-vingts pour cent des faits enregistrés s'évaporaient dans la fenêtre entre lecture et réécriture.
Une entrée perdue ne se contente pas de manquer dans une archive. Ce registre est consulté avant chaque publication pour répondre à une question simple : ce produit est-il déjà sorti aujourd'hui ? Un produit dont l'entrée a disparu est réputé n'avoir jamais été publié. Il est donc republié le lendemain. Les lecteurs reçoivent deux fois le même contenu, et la trace qui aurait permis de le comprendre est justement celle qui manque.
Un bug de concurrence sur trois lignes se transforme ainsi en défaut visible par l'utilisateur final, avec un délai de vingt-quatre heures entre la cause et le symptôme. C'est le profil de panne le plus coûteux à diagnostiquer.
Quand la republication est apparue, le premier réflexe a été de suspecter la logique anti-doublon : mauvaise fenêtre de cadence, mauvais fuseau horaire, mauvaise clé de comparaison. Cette logique était juste. Elle interrogeait un registre amputé. Un contrôle correct sur une donnée perdue rend un verdict faux avec une parfaite assurance.
Le correctif tient dans un changement de format et un changement d'appel système. Le
registre devient un fichier de lignes JSON, une ligne par publication, et l'écriture se
fait en mode ajout, drapeau O_APPEND. Le noyau garantit que le décalage
d'écriture est calculé au moment de l'écriture, pas au moment de l'ouverture : deux
processus qui ajoutent en même temps produisent deux lignes, dans un ordre non garanti,
mais toutes les deux présentes.
// Le registre : une ligne par fait, ajoutée, jamais relue-réécrite fs.appendFileSync(CHEMIN, JSON.stringify({ type, at, canaux }) + "\n"); // Sur disque {"type":"signals","at":"2026-08-11T13:34:39.500Z","canaux":["web","telegram"]} {"type":"retro", "at":"2026-08-11T13:34:39.641Z","canaux":["web","telegram"]}
Le bénéfice secondaire est arrivé sans être cherché. Deux exécuteurs travaillent sur des clones distincts du même dépôt, un poste local et une routine distante. Avec un tableau JSON monolithique, leur convergence produisait des marqueurs de conflit à l'intérieur du fichier. Effet de bord redoutable : la publication continuait de passer, mais l'enregistrement échouait, donc le même produit était jugé « jamais publié » à chaque exécution suivante. Indéfiniment.
Avec un fichier de lignes, la stratégie de fusion devient une union de lignes, déclarée au niveau du dépôt. C'est la bonne sémantique : deux publications distinctes sont deux faits, pas un conflit. Personne n'a besoin de trancher entre elles.
Un registre append-only reste lisible par quelqu'un, et cette lecture a ses propres pièges. Deux d'entre eux nous ont coûté des soirées.
Absent et illisible sont deux choses différentes. Un fichier tronqué, traité comme « rien n'a jamais été publié », rouvre le quota en grand. Le premier démarrage légitime doit passer ; une ligne abîmée doit fermer le quota tout en laissant le reste fonctionner. Notre lecteur garde donc ce qu'il a su lire et lève un drapeau séparé : le canal courriel se ferme, le web continue, et personne n'est paralysé par une ligne corrompue vieille de trois semaines.
Les chemins s'ancrent sur le dépôt, jamais sur le répertoire courant. Un chemin relatif transformait le quota en passoire : lancé depuis un sous-dossier, l'outil lisait un registre vide et autorisait tout. Les sous-processus et les routines distantes n'ont aucune raison de partager le répertoire de travail de l'opérateur.
Le second registre est plus sensible. Il contient l'historique des trades clôturés, celui sur lequel sont calculées les performances publiées. Sa réécriture n'a pas été accidentelle : elle était prévue par le code. Chaque exécution du moteur de simulation recalculait l'ensemble de l'historique.
Pendant cinq mois, des bugs successifs du moteur ont donc réécrit silencieusement le passé. Une stratégie est passée de +91 % à +77 % du jour au lendemain, sans qu'aucune position n'ait changé. Quarante et un changements de configuration en cinquante jours déclenchaient à chaque fois une re-simulation complète. Le problème n'est pas qu'un chiffre soit devenu faux : c'est qu'aucun chiffre publié n'était plus opposable, y compris les justes.
Chaque trade clos est réduit à sa forme canonique : symbole, date de détection, date d'entrée, date de sortie, prix d'entrée réel, prix de sortie, performance, statut, stratégie. Cette forme est hachée en SHA-256. Le hachage du trade est ensuite combiné au hachage du bloc précédent pour produire le hachage du bloc courant. Une chaîne, au sens strict.
function hashTrade(t) { const canonique = JSON.stringify({ symbole: t.symbole, dateDetection: t.dateDetection, dateEntree: t.dateEntree, dateSortie: t.dateSortie, entree: t.entree, sortie: t.sortie, perf: t.perf, statut: t.statut, strategie: t.strategie, }); return sha256(canonique); } function construireChaine(trades) { let precedent = "0000000000000000"; return trades.map(t => { const bloc = sha256(precedent + hashTrade(t)); precedent = bloc; return { hashTrade: hashTrade(t), bloc, symbole: t.symbole }; }); }
La vérification tourne au démarrage du moteur, avant tout calcul. Elle rejoue la chaîne enregistrée contre les trades présents et s'arrête au premier écart, en nommant l'index exact et le symbole concerné. Elle distingue deux fautes, ce qui compte pour le diagnostic : un bloc dont le hachage ne correspond plus signale une modification ; un bloc dont le trade n'existe simplement plus signale une suppression. Le moteur avorte dans les deux cas.
Une garde complémentaire couvre le cas dégénéré que la chaîne seule ne verrait pas : le nombre de trades clos ne peut jamais décroître. Une chaîne vide se vérifie parfaitement.
| Événement | Détecté par | Réaction |
|---|---|---|
| Un prix de sortie est corrigé sur un trade clos | hachage de bloc ≠ hachage enregistré | Abandon, index et symbole nommés |
| Un trade clos disparaît de l'historique | chaîne plus longue que les trades | Abandon, position du trade supprimé |
| L'historique est vidé puis reconstruit | compte de trades clos en baisse | Abandon |
| De nouveaux trades se clôturent | préfixe intact, longueur en hausse | Ajout autorisé, nouveaux blocs scellés |
Le même principe gouverne les agrégats. Les statistiques scellées d'une stratégie ne sont jamais recalculées en entier une fois qu'elles existent : le segment postérieur au gel avance à partir des performances déjà scellées, et le préfixe reste verrouillé octet par octet. Un calcul sur toute la période n'a lieu qu'une seule fois dans la vie d'une stratégie, à la première clôture.
La règle protège les trades clos et les agrégats scellés. Elle ne gèle pas un contenu publié. Un article ou une page qui contient une erreur le jour même doit être corrigé, et invoquer l'immutabilité pour refuser de le faire est un contresens que nous avons commis une fois. La frontière est nette : on ne réécrit pas l'histoire des faits, on corrige la description qu'on en donne.
Troisième cas, et le plus instructif sur ce qu'une autorisation doit être. Un courriel part chez de vrais abonnés et ne se rattrape pas. Notre règle : au plus un envoi par période de vingt-quatre heures, tous produits confondus, et il doit se mériter par un score de matérialité entier de 0 à 100, seuil à 70, accompagné d'une justification écrite d'au moins cent vingt caractères. Les deux sont persistés dans le registre : une barrière qui ne laisse aucune trace n'est pas auditable, et trois mois plus tard la question « pourquoi cet envoi est-il parti ? » n'a plus de réponse.
La première version proposait une commande de vérification : elle lisait le registre, calculait, et affichait un verdict. Elle n'écrivait rien. Deux appels successifs répondaient donc oui deux fois, et le double envoi ne demandait même pas une situation de concurrence, juste deux appels.
Le correctif est un principe : décider et enregistrer sont le même geste. Une seule commande autorise, et elle le fait sous verrou, dans un seul processus, où la vérification des barrières, la consommation du quota et l'émission de l'autorisation sont indissociables. Le quota est brûlé avant que l'autorisation existe, jamais après.
Restait un trou plus large que le premier. La commande savait dire non, mais rien n'obligeait à la consulter. Le point d'envoi acceptait un simple booléen, et un booléen ne lit pas de registre. Les barrières gardaient une porte à côté de laquelle il n'y avait pas de mur.
L'autorisation est devenue un jeton à usage unique écrit sur disque, émis sous verrou après consommation du quota, et consommé au point d'envoi. Pas de jeton valide, pas d'envoi, quel que soit l'appelant et quelle que soit sa bonne foi. Trois détails de conception font le reste du travail.
| Propriété | Valeur | Ce qu'elle empêche |
|---|---|---|
| Durée de vie | 10 minutes | Qu'une autorisation obtenue le matin serve le soir, alors que la matérialité qui la justifiait a expiré |
| Fenêtre de relais | 2 minutes | Que le second point de contrôle rejette ce que le premier vient légitimement de dépenser |
| Une consommation par rôle | 1 rôle = 1 fois | Que deux envois se partagent un jeton : le second présente le même rôle que le premier et se fait refuser |
Le jeton est vérifié à deux endroits distincts, un contrôle local avant le départ de l'appel et le service d'envoi lui-même quand il tourne près du dépôt. Les deux consomment. La fenêtre de relais existe pour que ce doublon de sécurité ne devienne pas un doublon de refus, et la règle « une consommation par rôle » garantit qu'elle ne serve jamais à faire passer deux envois.
Ces fichiers de jetons sont éphémères et portent une autorisation. Ils n'ont rien à faire dans le dépôt : un jeton committé est une autorisation qui voyage. La ligne d'exclusion fait partie du correctif, pas d'un nettoyage ultérieur.
L'ajout pur coûte peu et rend beaucoup. Le fichier grossit, ce qui se règle par une compaction hors ligne. La lecture demande un balayage, ce qui est négligeable sur des volumes de cet ordre. En échange : aucune écriture perdue, une fusion sans conflit entre clones, un historique opposable, et une reconstruction possible de n'importe quel état passé à partir des faits.
La question à se poser devant chaque fichier de données est courte : est-ce que ce fichier décrit ce qui est, ou ce qui a eu lieu ? La première catégorie se réécrit sans remords. La seconde, jamais.
Les incidents, volumes et paramètres cités proviennent de notre propre chaîne de publication et de son registre de simulation, à des dates identifiées. Cet article décrit des choix d'architecture logicielle et ne constitue pas un conseil financier. Les performances passées mentionnées illustrent un problème d'intégrité de données et ne préjugent pas des résultats futurs.