Suponha que um assistente de IA entregue um patch que troca um cache de valores por um de promises. Seu resumo promete três coisas: chamadores concorrentes compartilham uma leitura, leituras que falham são removidas para permitir novas tentativas e invalidação força uma nova leitura. O código é curto e familiar, mas quebra quando os eventos seguem uma ordem específica.

Registre o que o cache deve fazer

“Melhorar o cache” deixa todos os detalhes para quem escreve. Nesta revisão, o cache deve seguir cinco regras:

  1. Chamadas da mesma chave compartilham uma leitura pendente.
  2. Um resultado bem-sucedido permanece em cache até ser invalidado.
  3. Uma leitura que falha não permanece; a próxima chamada pode tentar de novo.
  4. A invalidação remove a entrada atual para que a próxima chamada inicie outra leitura.
  5. Quando uma leitura antiga, já removida, termina, não pode remover nem substituir uma entrada mais recente.

A regra 4 afeta apenas consultas futuras. Não cancela uma leitura em andamento nem retira um valor que um chamador já espera. Se o produto exige dados mais recentes para todos, esse cache não basta mesmo sem bugs.

A chave também precisa incluir tudo o que muda o resultado. Um caminho pode bastar para carregar um arquivo local; um cache HTTP talvez precise de usuário, permissões, consulta ou revisão.

O patch

Se você armazena o valor só após await read(key), duas chamadas simultâneas não encontram nada e ambas leem. Armazenar a promise pendente resolve isso. Adicione limpeza de falhas e terá algo assim:

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);
}

A promise é armazenada antes de a leitura começar, permitindo que outro chamador a compartilhe. Quando a leitura falha, o tratador exclui a chave. Mas exclui o que estiver nessa posição agora, não necessariamente a promise que falhou.

Observe que invalidate não mudou no patch. É fácil pular código inalterado, e sem ele a limpeza parece um tratamento rotineiro. O bug só aparece ao ler as duas funções juntas.

A ordem que provoca a falha

Chame de A e B duas leituras da mesma chave:

Etapa Evento Entrada do cache
1 O primeiro load(key) inicia A promise A
2 invalidate(key) executa ausente
3 O segundo load(key) inicia B promise B
4 A falha; a limpeza exclui key ausente
5 O terceiro load(key) executa com B pendente promise C; leitura duplicada

Na etapa 4, a leitura que falhou exclui uma entrada que agora pertence a B. Neste exemplo, o dano observável é trabalho extra: B e C leem a mesma chave simultaneamente. Não chame isso de corrupção de dados sem confirmar que os efeitos do leitor real poderiam corromper algo.

A tabela mostra exatamente o gatilho: invalidação durante uma leitura pendente, nova consulta e falha da leitura antiga. Um teste que carrega, invalida e carrega novamente depois de tudo terminar nunca o alcança.

A correção: confira se a entrada ainda é sua

Antes de excluir, confira se a posição ainda contém a promise que falhou:

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);
    },
  };
}

A verificação de identidade é toda a correção. A presença da chave não significa que a entrada ainda é sua; outra requisição pode ocupá-la. Verificar entries.has(key) no tratador continuaria errado.

Há dois detalhes menores. Promise.resolve().then(() => read(key)) transforma um lançamento síncrono de read em rejeição e garante que a entrada seja armazenada antes de read executar. Se read for sempre assíncrona, pode escrever de outro modo, mas decida explicitamente o comportamento de erros síncronos e chamadas reentrantes. Além disso, a limpeza usa um .catch() separado, então os chamadores recebem a promise original e veem a rejeição. Converter falhas em um valor alternativo mudaria a API, uma decisão separada.

Teste enquanto as promises estão pendentes

Controle quando cada leitura termina com promises adiadas, sem temporizadores. Este é o teste de regressão 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 a primeira leitura; o exemplo completo também tem b para a segunda e a resolve no final. assert.rejects é conectado antes da rejeição de A, evitando que o teste provoque uma rejeição não tratada própria.

Comparar identidade funciona nesta implementação, mas conte também as leituras subjacentes. Duas promises externas podem compartilhar uma leitura, e duas leituras diferentes podem retornar valores iguais. As regras exigem uma leitura, não um objeto promise específico.

Depois cubra os estados ao redor:

Teste O que verificar
Duas chamadas antes da conclusão Uma leitura subjacente; ambos recebem o valor
Chamada posterior ao sucesso Valor em cache; nenhuma leitura extra
Falha seguida de nova chamada A nova leitura pode ter sucesso
Falha antiga após invalidação A substituta permanece em cache
Sucesso antigo após invalidação O chamador original recebe dados antigos; os futuros mantêm os novos
Falha síncrona do leitor A rejeição chega ao chamador; tentar novamente continua possível

A linha de sucesso antigo merece atenção. Este cache nunca grava no mapa ao ter sucesso, então uma leitura invalidada não se recoloca ao resolver. Seu próprio chamador ainda recebe o valor antigo. Confirme se isso é desejado.

Escreva um comentário verificável pelo autor

Um comentário de revisão poderia dizer:

O tratador de rejeição exclui pela chave sem conferir se a entrada ainda é a promise que falhou. Se A for invalidada enquanto pendente, B entrar em cache e A rejeitar depois, a limpeza de A remove B. A próxima consulta inicia uma terceira leitura enquanto B ainda executa, violando “uma leitura por entrada atual”. Compare a identidade antes de excluir; o teste invalidar-depois-rejeitar reproduz o problema.

Ele identifica posição, gatilho, efeito e teste. A gravidade depende dos chamadores reais: leituras locais duplicadas afetam desempenho; chamadas duplicadas a uma API paga custam dinheiro. Descubra isso antes de atribuir severidade.

Examine também a revisão base. Relate separadamente o que já estava quebrado, sem atribuir tudo à nova limpeza. Se o patch também reformatou arquivos ou atualizou dependências, pergunte o motivo. Mudanças alheias dificultam enxergar a alteração de comportamento.

Em uma revisão local, o visualizador de diff Git do Yenpo para Mac mantém a comparação de commit ou branch escolhida disponível enquanto você examina o contexto.

Execute por conta própria

Baixe promise-cache.mjs e execute com Node.js 22 ou posterior:

node --test promise-cache.mjs

Um dos seis testes é uma reprodução: passa quando a versão insegura manifesta o bug. Os outros cinco verificam o cache corrigido. O exemplo mantém tudo em memória e não tem expulsão, coordenação entre processos ou cancelamento. Em um patch real, confira também isso e os chamadores que montam chaves e chamam invalidate.

Com o AI Reader do Yenpo, abra uma comparação Git e clique em “Ask AI to review this comparison”. Confira o anexo e substitua o rascunho por um pedido específico:

Revise esta mudança de cache. Encontre chamadores que invalidam uma leitura pendente e iniciam uma substituta. Confira se o sucesso ou a falha da leitura antiga afeta a nova entrada. Cite o código, mostre a ordem de chamadas que provoca o problema e separe problemas preexistentes de regressões.

Confira se os chamadores podem provocar essa ordem e execute o teste no terminal.

Se o patch também edita testes, revise as asserções alteradas junto da implementação. Um resultado verde pode ocultar regressão de chamador quando uma verificação de erro é substituída por condição mais fraca.