Supongamos que un asistente de IA te entrega un parche que sustituye una caché de valores por una de promesas. Su resumen promete tres cosas: los llamadores concurrentes comparten una lectura, las lecturas fallidas se descartan para permitir reintentos y la invalidación fuerza una lectura nueva. El código es corto y familiar, pero falla cuando los eventos siguen un orden concreto.

Anota qué debe hacer la caché

«Mejora la caché» deja todos los detalles en manos de quien escribe. Para esta revisión debe cumplir cinco reglas:

  1. Las llamadas a la misma clave comparten una lectura pendiente.
  2. Un resultado correcto permanece en caché hasta invalidarlo.
  3. Una lectura fallida no permanece; la siguiente llamada puede reintentar.
  4. Invalidar elimina la entrada actual y la siguiente llamada inicia otra lectura.
  5. Cuando termina una lectura antigua ya eliminada, no puede eliminar ni sustituir una entrada nueva.

La regla 4 solo afecta a búsquedas futuras. No cancela una lectura en curso ni retira el valor que alguien ya está esperando. Si el producto exige que todos reciban los datos más recientes, esta caché no basta incluso sin errores.

La clave debe incluir todo lo que cambie el resultado. Una ruta puede bastar para cargar un archivo local; una caché HTTP podría necesitar también el usuario, sus permisos, la consulta o una revisión.

El parche

Si guardas el valor solo después de await read(key), dos llamadas simultáneas no encuentran la entrada y ambas leen. Guardar la promesa en curso lo resuelve. Añade limpieza de lecturas fallidas y tendrás algo así:

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 promesa se guarda antes de iniciar la lectura para compartirla con otro llamador. Al fallar, el manejador elimina la clave. Pero elimina lo que haya ahora en ese lugar, que no tiene por qué ser la promesa fallida.

Observa que invalidate no cambió. Es fácil saltarse código intacto al revisar y, sin él, la nueva limpieza parece manejo habitual de errores. El fallo solo aparece al leer juntas ambas funciones.

El orden que lo rompe

Llama A y B a dos lecturas de la misma clave:

Paso Evento Entrada de caché
1 El primer load(key) inicia A Promesa A
2 Se ejecuta invalidate(key) Ausente
3 El segundo load(key) inicia B Promesa B
4 A falla; su limpieza elimina key Ausente
5 El tercer load(key) se ejecuta mientras B sigue pendiente Promesa C; lectura duplicada

En el paso 4, la lectura fallida elimina una entrada que pertenece a B. Aquí el daño visible es trabajo adicional: B y C leen la misma clave a la vez. No lo llames corrupción de datos salvo que los efectos secundarios del lector real puedan corromper algo.

La tabla define el desencadenante: invalidación durante una lectura pendiente, nueva búsqueda y fallo posterior de la lectura antigua. Una prueba que carga, invalida y vuelve a cargar después de que todo termine nunca lo alcanzará.

La corrección: comprueba que la entrada aún es tuya

Antes de eliminar, comprueba que el lugar aún contiene la promesa fallida:

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 comprobación de identidad es toda la corrección. Que exista la clave no implica que la entrada siga siendo tuya; una solicitud nueva puede ocuparla. Comprobar entries.has(key) en el manejador seguiría siendo incorrecto.

Hay dos detalles más. Promise.resolve().then(() => read(key)) convierte una excepción síncrona de read en una promesa rechazada y garantiza que se guarde la entrada antes de ejecutar read. Si read siempre es asíncrona puedes escribirlo de otro modo, pero decide expresamente cómo tratar errores síncronos y llamadas reentrantes. Además, la limpieza usa un .catch() separado: los llamadores reciben la promesa original y ven el rechazo. Convertir fallos en un valor alternativo cambiaría la API y requiere otra decisión.

Prueba mientras las promesas estén pendientes

Controla cuándo termina cada lectura mediante promesas diferidas, no temporizadores. Esta es la prueba de regresión importante:

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 controla la primera lectura; el ejemplo completo también tiene b para la segunda y la resuelve al final. assert.rejects se conecta antes de rechazar A para que la propia prueba no genere un rechazo sin gestionar.

Comparar identidad de promesas funciona aquí, pero cuenta también las lecturas subyacentes. Dos promesas envolventes pueden compartir una lectura y dos lecturas distintas devolver valores iguales. Las reglas exigen una lectura, no un objeto promesa concreto.

Cubre después los estados cercanos:

Prueba Qué afirmar
Dos llamadas antes de terminar Una lectura subyacente; ambos reciben el valor
Llamada posterior al éxito Valor en caché; sin otra lectura
Fallo seguido de otra llamada La lectura nueva puede completarse
Fallo antiguo después de invalidar La sustitución permanece en caché
Éxito antiguo después de invalidar El llamador original recibe datos antiguos; los futuros conservan los nuevos
Fallo síncrono del lector El rechazo llega al llamador; se puede reintentar

Revisa la fila de éxito antiguo. Esta caché nunca escribe en el mapa al completarse, por lo que una lectura invalidada no puede reinsertarse al resolverse. Su propio llamador sí recibe el valor antiguo. Confirma que es lo deseado.

Escribe un comentario que el autor pueda comprobar

Un comentario de revisión podría decir:

El manejador de rechazo elimina por clave sin comprobar si la entrada sigue siendo la promesa fallida. Si A se invalida mientras está pendiente, B se guarda y luego A se rechaza, la limpieza de A elimina B. La siguiente búsqueda inicia una tercera lectura mientras B sigue ejecutándose, incumpliendo «una lectura por entrada actual». Compara la identidad de promesa antes de eliminar; la prueba invalidar-y-rechazar lo reproduce.

Indica ubicación, desencadenante, efecto y prueba. La gravedad depende de los llamadores reales: duplicar lecturas locales afecta al rendimiento; duplicar llamadas a una API de pago cuesta dinero. Averígualo antes de asignar gravedad.

Examina también la revisión base. Si algo ya fallaba, descríbelo por separado sin atribuirlo a la nueva limpieza. Si el parche de IA también reformateó archivos o actualizó dependencias, pregunta por qué. Los cambios ajenos dificultan ver el cambio real de comportamiento.

Para una revisión local, el visor de diffs de Git de Yenpo para Mac mantiene disponible una comparación de commits o ramas mientras examinas el contexto del parche.

Ejecútalo

Descarga promise-cache.mjs y ejecútalo con Node.js 22 o posterior:

node --test promise-cache.mjs

Una de las seis pruebas reproduce el fallo: pasa cuando la versión insegura lo muestra. Las otras cinco comprueban la caché corregida. Todo permanece en memoria y no hay expulsión de entradas, coordinación entre procesos ni cancelación. En un parche real comprobarías también eso, además de quienes construyen claves y llaman a invalidate.

Con AI Reader de Yenpo, abre una comparación de Git y pulsa «Ask AI to review this comparison». Comprueba el adjunto y sustituye el borrador por una petición concreta:

Revisa este cambio de caché. Busca llamadas que puedan invalidar una lectura pendiente e iniciar una sustitución. Comprueba si el éxito o fallo de la lectura antigua afecta a la entrada nueva. Cita el código relevante, muestra el orden desencadenante y separa problemas previos de regresiones.

Comprueba que las llamadas pueden seguir ese orden y ejecuta la prueba en tu terminal.

Si el parche modifica pruebas, revisa las aserciones que cambian junto a la implementación. Un resultado verde puede ocultar una regresión del llamador si una comprobación de error se sustituye por una condición más débil.