Supposons qu’un assistant IA remplace un cache de valeurs par un cache de promesses. Son résumé promet trois choses : les appelants simultanés partagent une lecture, les lectures échouées sont retirées pour permettre une nouvelle tentative, et l’invalidation force une lecture fraîche. Le code est court et familier. Il casse pourtant avec un ordre précis d’événements.

Écrire ce que le cache doit faire

« Améliorer le cache » laisse tous les détails à l’auteur. Pour cette revue, cinq règles s’appliquent :

  1. Les appels pour la même clé partagent une lecture en cours.
  2. Un résultat réussi reste en cache jusqu’à invalidation.
  3. Une lecture échouée ne persiste pas ; l’appel suivant peut réessayer.
  4. L’invalidation retire l’entrée actuelle pour que l’appel suivant démarre une nouvelle lecture.
  5. Une ancienne lecture déjà retirée ne peut pas supprimer ou remplacer une entrée plus récente lorsqu’elle termine.

La règle 4 ne concerne que les recherches futures. Elle n’annule pas une lecture en cours ni une valeur déjà attendue. Si chaque appelant doit voir les données les plus récentes, ce cache ne suffit pas, même sans bug.

La clé doit aussi inclure tout ce qui modifie le résultat. Un chemin peut suffire pour un fichier local ; un cache HTTP peut exiger l’utilisateur, ses droits, la requête ou une révision.

Le correctif

Si la valeur n’est mise en cache qu’après await read(key), deux appels simultanés manquent tous deux le cache et lisent chacun. Mettre en cache la promesse en cours résout cela. Ajoutez le nettoyage des échecs et vous obtenez ceci :

function load(key) {
  if (entries.has(key)) return entries.get(key);
  const pending = Promise.resolve().then(() => read(key));
  entries.set(key, pending);
  void pending.catch(() => {
    entries.delete(key);
  });
  return pending;
}

function invalidate(key) {
  entries.delete(key);
}

La promesse est stockée avant le début de la lecture, pour être partagée. En cas d’échec, le gestionnaire supprime la clé. Mais il supprime ce qui occupe maintenant cet emplacement, pas forcément la promesse échouée.

Notez que invalidate n’a pas changé. Le code inchangé est facile à ignorer ; sans lui, le nettoyage paraît banal. Le bug n’apparaît qu’en lisant les deux fonctions ensemble.

L’ordre qui le casse

Appelons A et B deux lectures de la même clé :

Étape Événement Entrée du cache
1 Le premier load(key) démarre A promesse A
2 invalidate(key) s’exécute absente
3 Le deuxième load(key) démarre B promesse B
4 A échoue ; son nettoyage supprime key absente
5 Le troisième load(key) arrive pendant B promesse C ; lecture en double

À l’étape 4, la lecture échouée supprime l’entrée de B. Ici, le dommage visible est du travail supplémentaire : B et C lisent la même clé en même temps. Ne parlez de corruption de données que si les effets du vrai lecteur peuvent réellement en provoquer.

Le tableau donne le déclencheur exact : invalidation pendant une lecture, nouvelle recherche, puis échec de l’ancienne lecture. Un test qui charge, attend la fin, invalide et recharge ne l’atteint jamais.

La correction : vérifier que l’entrée est toujours la vôtre

Avant de supprimer, vérifiez que l’emplacement contient toujours la promesse échouée :

export function createCache(read) {
  const entries = new Map();
  return {
    load(key) {
      if (entries.has(key)) return entries.get(key);
      const pending = Promise.resolve().then(() => read(key));
      entries.set(key, pending);
      void pending.catch(() => {
        if (entries.get(key) === pending) entries.delete(key);
      });
      return pending;
    },
    invalidate(key) {
      entries.delete(key);
    },
  };
}

La vérification d’identité est toute la correction. La présence de la clé ne prouve pas que l’entrée vous appartient encore : une nouvelle requête peut l’avoir remplacée. Vérifier entries.has(key) resterait incorrect.

Deux détails comptent. Promise.resolve().then(() => read(key)) transforme un lancer synchrone de read en promesse rejetée et garantit que l’entrée est stockée avant read. Si read est toujours asynchrone, une autre écriture est possible, mais décidez du comportement des erreurs synchrones et appels réentrants. Le nettoyage utilise aussi un .catch() séparé : les appelants reçoivent la promesse originale et son rejet. Transformer les échecs en valeur de repli changerait l’API ; c’est une autre décision.

Tester pendant que les promesses sont en attente

Contrôlez la résolution de chaque lecture avec des promesses différées plutôt que des minuteurs. Voici le test de régression essentiel :

const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // Let the injected reader start A.
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // Let the injected reader start B.
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);

a contrôle la première lecture ; l’exemple complet utilise aussi b pour la seconde et la résout à la fin. assert.rejects est attaché avant le rejet de A, évitant un rejet non géré créé par le test lui-même.

Comparer l’identité des promesses fonctionne ici, mais comptez aussi les lectures sous-jacentes. Deux promesses enveloppes peuvent partager une lecture, et deux lectures distinctes renvoyer la même valeur. Les règles exigent une lecture, pas un objet promesse précis.

Couvrez ensuite les états voisins :

Test Assertion à vérifier
Deux appels avant la fin Une lecture ; les deux appelants reçoivent la valeur
Appel ultérieur après réussite Valeur en cache ; aucune lecture supplémentaire
Échec suivi d’un nouvel appel Une nouvelle lecture peut réussir
Ancien échec après invalidation Le remplacement reste en cache
Ancienne réussite après invalidation L’appelant original reçoit l’ancien résultat ; les suivants gardent le nouveau
Échec synchrone du lecteur Le rejet atteint l’appelant ; une nouvelle tentative reste possible

La ligne de l’ancienne réussite mérite attention. Ce cache n’écrit jamais dans la map lors du succès, donc une lecture invalidée ne peut pas se réinsérer en se résolvant. Son appelant reçoit néanmoins l’ancienne valeur. Assurez-vous que c’est voulu.

Écrire un commentaire vérifiable par l’auteur

Un commentaire de revue pourrait dire :

Le gestionnaire de rejet supprime par clé sans vérifier que l’entrée est encore la promesse échouée. Si A est invalidée pendant l’attente, B est mise en cache, puis A est rejetée, le nettoyage de A supprime B. La recherche suivante lance une troisième lecture pendant B, violant « une lecture par entrée actuelle ». Compare l’identité avant suppression ; le test invalidate-then-reject le reproduit.

Le commentaire nomme l’emplacement, le déclencheur, l’effet et un test. La gravité dépend des vrais appelants : des lectures locales doublées coûtent en performance, des appels API payants coûtent de l’argent. Vérifiez cela avant de fixer une priorité.

Examinez aussi la révision de base. Signalez séparément les problèmes antérieurs, sans les attribuer au nouveau nettoyage. Si le correctif IA reformate aussi des fichiers ou met à jour des dépendances, demandez pourquoi. Les changements sans rapport masquent le comportement réellement modifié.

Pour une revue locale, le lecteur Git diff de Yenpo pour Mac garde la comparaison choisie disponible pendant que vous inspectez le contexte du correctif.

Exécuter vous-même

Téléchargez promise-cache.mjs et lancez-le avec Node.js 22 ou ultérieur :

node --test promise-cache.mjs

Un des six tests reproduit le bug et passe quand la version non sûre le manifeste. Les cinq autres vérifient le cache corrigé. Tout reste en mémoire, sans éviction, coordination interprocessus ni annulation. Pour un vrai correctif, vérifiez aussi ces points et les appelants qui construisent les clés et appellent invalidate.

Avec l’AI Reader de Yenpo, ouvrez une comparaison Git et cliquez sur « Demander à l’IA d’examiner cette comparaison ». Vérifiez la comparaison jointe, puis remplacez le brouillon par une demande ciblée :

Examine ce changement de cache. Trouve les appelants pouvant invalider une lecture en cours et lancer un remplacement. Vérifie si le succès ou l’échec de l’ancienne lecture affecte la nouvelle entrée. Cite le code, montre l’ordre déclencheur et sépare problèmes existants et régressions.

Vérifiez que les appelants peuvent déclencher cet ordre, puis lancez le test dans le terminal.

Si le correctif modifie aussi les tests, examinez les assertions changées avec l’implémentation. Un résultat vert peut masquer une régression d’appelant lorsqu’un contrôle d’erreur est remplacé par une condition plus faible.