{ "index": 81, "slug": "editorial-2025-10-practice-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", "excerpt": "Promise.all объединяет результаты асинхронных операций, но не управляет их остановкой. Разбираем модель, контрпример, проверку и отдельный контракт отмены.", "contentHtml": "

Симптом появляется после первой же ошибки: один запрос отклонился, обработчик получил исключение, а соседние запросы продолжают выполняться. В логах уже виден общий отказ, но база данных, сетевой клиент или внешний сервис ещё получают работу. Цена ошибки — лишний трафик, ненужные записи, гонки при очистке и неверное сообщение пользователю. Если эту границу объяснить неточно, команда начнёт искать отмену в месте, которое умеет только ждать.

\n

Тезис простой: Promise.all описывает исход группы promises, а не жизненный цикл операций, которые за ними стоят. Aggregate promise может отклониться на первой ошибке. Остальные входы при этом не получают приказ остановиться. Значит, ожидание и отмена — два разных контракта. Их нужно проектировать и проверять раздельно.

\n

Модель: что именно объединяет Promise.all

\n

Представьте два входа: left и right. Каждый из них обещает значение в будущем. Вызов Promise.all([left, right]) создаёт третий promise. Он становится успешным, когда все входы успешны, и отклоняется, когда один вход отклоняется. При успехе значения идут в том же порядке, что и входы.

\n

Третий promise не владеет внутренней работой left и right. Он наблюдает их состояния и сообщает общий результат. Если left завершился ошибкой, aggregate promise может завершиться сразу. Уже начатый right не обязан завершаться в тот же момент. Если right сам изменяет состояние внешней системы, наблюдение за ним не откатывает это изменение.

\n
Схема: два входных promise сходятся в общий результат, а отмена остаётся отдельным контрактом
Общий promise сообщает результат группы. Отдельный сигнал отмены управляет конкретной операцией, если такой сигнал предусмотрен её API.
\n

Минимальный пример с видимым порядком событий

\n

Учебный пример ниже не обращается к сети, файлам или базе. Он вручную завершает второй promise после того, как общий promise уже отклонился. Так видна только семантика ожидания. Пример не доказывает, что какой-либо реальный клиент продолжит работу именно с такой задержкой.

\n
let finishRight;\n\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    console.log('right finished');\n    resolve('cache value');\n  };\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('left failed')),\n  right,\n]).catch(() => {\n  console.log('combined rejected');\n});\n\nawait combined;\nfinishRight();\nawait right;
\n

Сначала появится combined rejected. Затем появится right finished. Это не означает, что aggregate promise «забыл» второй вход. Он уже сообщил общий отказ, но второй вход всё ещё может перейти в состояние fulfilled. Ожидание результата группы не стало командой отмены.

\n

Важна и другая деталь. Promise.all не создаёт сами операции. К моменту вызова promises часто уже запущены: функция вернула promise, сетевой запрос уже отправлен, чтение уже поставлено в очередь. Обёртка видит результат этой работы, но не получает универсального доступа к её внутренним ресурсам.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок вокруг Promise.all
СимптомПричинаПроверкаДействие
После первой ошибки соседний запрос всё ещё виден в логахAggregate promise сообщает отказ, но не отменяет входДобавить отдельные метки старта и завершения каждой операцииРешить, нужна ли отмена, и передать сигнал в API операции
Код ловит общий отказ и считает работу законченнойСмешаны состояние aggregate promise и состояние ресурсовПроверить, что происходит с каждым входом после catchДождаться нужных cleanup-действий или описать их отдельный контракт
Пользователь нажал «отмена», но запрос продолжилсяКнопка меняет интерфейс, а сигнал не дошёл до транспортаПроверить прохождение AbortSignal до вызова APIСвязать действие пользователя с поддерживаемым механизмом отмены
После ошибки меняется общий объектОдна из операций имеет побочный эффектСравнить состояние до запуска, после отказа и после завершения остальных входовСделать операцию идемпотентной, добавить компенсацию или изменить порядок
В сообщении указано «все запросы остановлены»Вывод сделан по первой ошибке, а не по наблюдению ресурсовНайти подтверждение остановки для каждого участника группыСузить формулировку до «общий результат отклонён»
\n

Как выглядит отдельная отмена

\n

Отмена появляется только там, где её поддерживает исполнитель. Для браузерного fetch обычно передают AbortSignal, полученный от AbortController. Контроллер не превращает любой promise в отменяемый. Он передаёт сигнал в конкретный API, а API решает, как остановить или прервать свою работу.

\n
const controller = new AbortController();\n\nconst requests = [\n  fetch('/api/profile', { signal: controller.signal }),\n  fetch('/api/limits', { signal: controller.signal }),\n];\n\ntry {\n  const responses = await Promise.all(requests);\n  // Обрабатываем ответы только после успеха всей группы.\n} catch (error) {\n  if (error.name === 'AbortError') {\n    // Это отдельный путь отмены, а не обычная ошибка данных.\n  }\n  throw error;\n}\n\n// Вызывается по явному решению приложения, например по кнопке.\ncontroller.abort();
\n

В этом фрагменте есть важное ограничение: вызов abort() стоит после try только для наглядности механизма. В рабочем коде его вызывают из отдельного события или политики таймаута. Если один fetch уже успел изменить серверное состояние, прекращение ожидания ответа не отменит это изменение. Для серверной операции нужен серверный контракт: ключ идемпотентности, отмена задания, транзакция или компенсационное действие.

\n

Не следует использовать контроллер как универсальный откат. Он помогает остановить поддерживаемую передачу данных. Он не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную функцию, которая просто вернула promise.

\n

Положительный и отрицательный пути

\n

Положительный путь начинается с успешных входов. Тогда aggregate promise возвращает массив значений в порядке входного массива. Это удобно, когда зависимый код может начать работу только после получения всех значений. Проверка должна подтвердить не только наличие двух ответов, но и соответствие позиции: первый результат относится к первому входу.

\n

Отрицательный путь начинается с отклонения одного входа. Aggregate promise сообщает ошибку, но у приложения остаются вопросы. Нужно ли дождаться остальных результатов? Нужно ли отменить их? Можно ли показать частичные данные? Нужна ли компенсация побочного эффекта? На эти вопросы Promise.all не отвечает. Если нужен итог каждого входа, включая ошибки, применяют Promise.allSettled. Если нужна остановка, добавляют API отмены. Если нужна повторная попытка, описывают её лимит и область действия отдельно.

\n

Отдельно проверяйте поздние события. Отмена может прийти после ответа сервера. Таймаут может сработать после записи на сервере. Первый отказ может появиться раньше, чем второй запрос заметит закрытый сигнал. Обработчик должен различать «мы перестали ждать», «транспорт получил отмену» и «внешняя операция действительно прекратилась». Это три разных наблюдения.

\n

Порядок проверки

\n
  1. Опишите каждую операцию отдельно: что она читает, что меняет и какой ресурс должен остановиться.
  2. Зафиксируйте ожидаемый общий результат: все значения, первая ошибка или полный набор исходов.
  3. Запустите два входа с разными задержками и запишите отдельные события старта, отказа, завершения и отмены.
  4. Проверьте сценарий, в котором первый вход отклоняется, а второй завершается позже.
  5. Если нужна отмена, передайте поддерживаемый сигнал в каждый исполнитель и проверьте его получение.
  6. Проверьте поздний ответ после отмены: он не должен менять UI или состояние, если операция уже устарела.
  7. Проверьте побочные эффекты отдельно: отмена ожидания не должна называться откатом без подтверждения внешней системы.
\n

Ограничения модели

\n

Эта модель объясняет семантику группы promises, но не задаёт политику приложения. Она не решает, какой запрос важнее, как долго ждать, сколько раз повторять и как сообщать частичный результат. Для этих решений нужны отдельные правила и наблюдаемые сигналы.

\n

Она также не заменяет документацию конкретной библиотеки. Один клиент может поддерживать AbortSignal, другой — собственный метод отмены, третий — только закрытие соединения. Нельзя переносить поведение одного транспорта на другой по одному имени promise.

\n

Учебный код с ручным finishRight показывает порядок переходов в памяти. Он не является нагрузочным тестом, не измеряет задержки и не подтверждает поведение production-системы. В реальном сервисе проверяйте контракт библиотеки, логи транспорта и состояние внешнего ресурса.

\n

Критерий готовности

\n

Объяснение и код готовы, если читатель может ответить на четыре вопроса без догадки: какой promise сообщает общий результат, что происходит с остальными входами после первой ошибки, какой объект или API действительно принимает отмену и что происходит с уже выполненным побочным эффектом. В проверочном запуске должны быть видны отдельные события для aggregate promise и каждой операции. Для сценария отмены нужно подтвердить получение сигнала исполнителем, а для серверного изменения — отдельное подтверждение остановки или компенсации.

\n

Если на вопрос об остановке отвечают только названием Promise.all, объяснение не готово. Добавьте контрпример и укажите владельца отмены. Если такого владельца нет, честный результат звучит так: общий promise отклонился, а начатая работа может продолжиться.

\n

Проверяемые источники

" }