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:
- Chamadas da mesma chave compartilham uma leitura pendente.
- Um resultado bem-sucedido permanece em cache até ser invalidado.
- Uma leitura que falha não permanece; a próxima chamada pode tentar de novo.
- A invalidação remove a entrada atual para que a próxima chamada inicie outra leitura.
- 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.