Допустим, ИИ-ассистент предлагает патч замены кэша значений на кэш промисов. Сводка обещает три вещи: параллельные вызовы разделяют одно чтение, неудачные чтения удаляются для повтора, инвалидация требует нового чтения. Код короткий и знакомый. Но при определённом порядке событий он ломается.
Запишите требования к кэшу
«Улучши кэширование» оставляет детали автору. Для этого ревью кэш должен соблюдать пять правил:
- Вызовы одного ключа разделяют одно ожидающее чтение.
- Успешный результат остаётся в кэше до инвалидации.
- Неудачное чтение не сохраняется; следующий вызов может повторить попытку.
- Инвалидация удаляет текущую запись, чтобы следующий вызов начал новое чтение.
- Старое уже удалённое чтение при завершении не может удалить или заменить новую запись.
Правило 4 влияет только на будущие обращения. Оно не отменяет текущее чтение и не забирает значение, которого уже ждёт вызов. Если всем потребителям нужны самые свежие данные, такого кэша недостаточно даже без ошибок.
Ключ кэша должен включать всё, что меняет результат. Для локального загрузчика может хватить пути, а HTTP-кэшу могут понадобиться пользователь, права, запрос или ревизия.
Патч
Если кэшировать значение лишь после await read(key), два одновременных вызова оба промахнутся и прочитают данные. Кэширование выполняющегося промиса решает это. Добавление очистки неудачных чтений даёт примерно такой код:
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);
}
Промис сохраняется до начала чтения, поэтому второй вызов может разделить его. При сбое обработчик удаляет ключ. Но удаляет то, что сейчас находится в ячейке, а не обязательно упавший промис.
Обратите внимание: invalidate в патче не менялся. Неизменённый код легко пропустить, и без него очистка выглядит обычной обработкой ошибки. Дефект виден только при совместном чтении функций.
Порядок, который ломает кэш
Назовём два чтения одного ключа A и B:
| Шаг | Событие | Запись кэша |
|---|---|---|
| 1 | Первый load(key) запускает A |
промис A |
| 2 | Выполняется invalidate(key) |
отсутствует |
| 3 | Второй load(key) запускает B |
промис B |
| 4 | A падает; очистка удаляет key |
отсутствует |
| 5 | Третий load(key) вызывается во время ожидания B |
промис C; дублирующее чтение |
На шаге 4 упавшее чтение удаляет запись, уже принадлежащую B. Здесь видимый ущерб — лишняя работа: B и C одновременно читают один ключ. Не называйте это повреждением данных, если реальные побочные эффекты не могут их повредить.
Таблица показывает точный триггер: инвалидация во время чтения, новое обращение, затем сбой старого чтения. Тест, который сначала дожидается завершения, а потом инвалидирует и загружает снова, этого не воспроизведёт.
Исправление: проверьте, что запись всё ещё ваша
Перед удалением проверьте, что ячейка всё ещё содержит упавший промис:
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);
},
};
}
Проверка идентичности — всё исправление. Наличие ключа не означает, что запись ещё ваша: новый запрос мог занять ячейку. Проверка entries.has(key) в обработчике всё ещё была бы неверной.
Важны ещё две детали. Promise.resolve().then(() => read(key)) превращает синхронный throw из read в отклонённый промис и гарантирует сохранение записи до запуска read. Если read всегда асинхронен, можно написать иначе, но поведение синхронных ошибок и повторного входа нужно выбрать явно. Очистка также подключена отдельным .catch(), поэтому вызовы получают исходный промис и его отклонение. Превращение ошибок в резервное значение изменило бы API и требует отдельного решения.
Тестируйте, пока промисы ожидают
Управляйте завершением чтений отложенными промисами, а не таймерами. Вот важный регрессионный тест:
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 управляет первым чтением; в полном примере есть b для второго, который завершается в конце. assert.rejects подключён до отклонения A, поэтому сам тест не создаёт необработанное отклонение.
Сравнение идентичности промисов подходит этой реализации, но считайте и фактические чтения. Два промиса-обёртки могут разделять одно чтение, а два отдельных чтения — возвращать равные значения. Правила требуют одного чтения, а не конкретного объекта промиса.
Затем проверьте соседние состояния:
| Тест | Что проверять |
|---|---|
| Два вызова до завершения | Одно чтение; оба получают значение |
| Поздний вызов после успеха | Значение из кэша; без дополнительного чтения |
| Сбой и новый вызов | Новое чтение может завершиться успешно |
| Старый сбой после инвалидации | Замена остаётся в кэше |
| Старый успех после инвалидации | Исходный вызов получает старые данные; будущие — новые |
| Синхронный сбой чтения | Отклонение доходит до вызывающего кода; повтор возможен |
Строка старого успеха заслуживает внимания. Кэш не пишет в map при успехе, поэтому инвалидированное чтение не может вернуть себя после завершения. Но исходный вызов всё равно получает старое значение. Убедитесь, что это желаемое поведение.
Напишите замечание, которое автор сможет проверить
Замечание ревью может звучать так:
Обработчик отклонения удаляет по ключу, не проверяя, что запись ещё содержит упавший промис. Если A инвалидируется в ожидании, B попадает в кэш, а затем A отклоняется, очистка A удаляет B. Следующее обращение запускает третье чтение, пока B ещё работает, нарушая «одно чтение на текущую запись». Сравни идентичность перед удалением; тест invalidate-then-reject воспроизводит ошибку.
Замечание называет место, триггер, эффект и тест. Серьёзность зависит от реальных вызовов: дублирование локального чтения снижает производительность, платных API-вызовов — тратит деньги. Выясните это перед назначением приоритета.
Проверьте и базовую ревизию. Старые проблемы сообщайте отдельно, не приписывая новой очистке. Если патч ИИ заодно форматирует файлы или обновляет зависимости, выясните зачем. Посторонние изменения мешают увидеть реальное изменение поведения.
Для локального ревью просмотр Git diff Yenpo для Mac держит выбранное сравнение коммитов или веток под рукой, пока вы изучаете окружающий код.
Запустите самостоятельно
Скачайте promise-cache.mjs и запустите с Node.js 22 или новее:
node --test promise-cache.mjs
Один из шести тестов воспроизводит ошибку и проходит, когда небезопасная версия её показывает. Остальные пять проверяют исправленный кэш. Пример хранит всё в памяти без вытеснения, межпроцессной координации и отмены. В реальном патче проверьте и это, а также код создания ключей и вызовы invalidate.
В AI Reader Yenpo откройте сравнение Git и нажмите «Попросить ИИ проверить это сравнение». Проверьте вложенное сравнение и замените черновик конкретным запросом:
Проверь изменение кэша. Найди вызовы, которые могут инвалидировать ожидающее чтение и запустить замену. Проверь влияние успеха или сбоя старого чтения на новую запись. Укажи код, покажи вызывающий порядок и отдели старые проблемы от регрессий.
Проверьте, могут ли вызовы привести к такому порядку, затем запустите тест в терминале.
Если патч меняет тесты, проверьте утверждения, изменённые вместе с реализацией. Зелёный результат может скрыть регрессию вызова, если проверку ошибки заменили более слабым условием.