{ "index": 80, "slug": "editorial-2025-10-mechanism-teaching-engineering", "title": "Promise.all не отменяет работу: как разделить результат и управление", "excerpt": "Разбираем на трассировке, что именно отклоняет Promise.all, почему соседние операции продолжаются и как связать отмену с AbortController, не выдавая запрос на остановку за откат побочных эффектов.", "contentHtml": "

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

\n

Такой симптом часто описывают словами «Promise.all остановил набор». Это неточное объяснение. Если его принять за контракт отмены, команда начнёт повторять сбор профиля поверх ещё работающего запуска или решит, что серверный побочный эффект уже невозможен. Правильный вопрос звучит иначе: какой объект сообщил об ошибке и каким механизмом владелец работы может остановить саму работу?

\n

Сначала разделим три уровня

\n

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

\n

Promise.all связывает исходы первых двух уровней. Он подписывается на входы, ждёт их успешного завершения и кладёт значения в массив по позиции входного iterable. Первый отказ переводит aggregate в состояние rejected. В этом алгоритме нет универсальной команды, которая должна остановить остальные входы или ресурс, породивший их.

\n
Матрица Promise.all: aggregate отклонён, отдельный вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены
Отклонённый aggregate — наблюдаемый исход композиции. Он не доказывает ни остановку входных promise, ни отмену внешней операции.
\n

Что гарантирует Promise.all

\n

У метода есть две полезные и проверяемые гарантии. При успехе он возвращает массив значений в порядке входного iterable, даже если второй запрос завершился раньше первого. При отказе он отклоняет возвращённый promise с причиной отказа входа, который первым отклонился. После этого ожидание aggregate заканчивается, но остальные входы всё ещё могут перейти в свой исход.

\n

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

\n
const trace = [];\nconst task = (name, delay, shouldReject = false) => new Promise((resolve, reject) => {\n  setTimeout(() => {\n    trace.push(name + ': finished');\n    if (shouldReject) {\n      reject(new Error(name + ': failed'));\n      return;\n    }\n    resolve(name);\n  }, delay);\n});\n\nconst tasks = [\n  task('settings', 10, true),\n  task('limits', 40),\n  task('history', 70),\n];\n\nawait Promise.all(tasks).catch((error) => {\n  trace.push('aggregate: ' + error.message);\n});\n\nconsole.log(trace);\n// ['settings: finished', 'aggregate: settings: failed']\n\nawait Promise.allSettled(tasks);\nconsole.log(trace);\n// ['settings: finished', 'aggregate: settings: failed',\n//  'limits: finished', 'history: finished']
\n

Сохраните фрагмент в promise-all-trace.mjs и запустите командой node promise-all-trace.mjs в Node.js с поддержкой ES-модулей. Первый вывод появляется примерно через 10 мс, второй — после окончания всех таймеров. Точные интервалы зависят от планировщика, но порядок событий задаётся задержками: aggregate уже отклонён, когда два других входа ещё не завершились.

\n

Это намеренно маленький тест. Таймер не моделирует TCP-соединение, запрос к базе или отмену на сервере. Он воспроизводит только границу между состоянием aggregate и состояниями уже запущенных входов.

\n

Порядок значений не равен порядку завершения

\n

Результат успешного вызова сопоставляется с исходным массивом, а не со скоростью задач. Вызов Promise.all([loadSettings(), loadHistory()]) вернёт настройки в позиции 0, историю в позиции 1, даже если история пришла раньше. Это делает композицию удобной, пока список входов не меняется между запуском и чтением.

\n

Если нужны статусы всех независимых операций, выбирайте Promise.allSettled. Он ждёт завершения каждого входа и возвращает элементы вида { status: 'fulfilled', value } или { status: 'rejected', reason }. Этот метод меняет форму отчёта, но не добавляет отмену: ни all, ни allSettled не знают, как остановить произвольную работу.

\n

Выбор комбинатора — это решение о зависимости

\n
Какой результат нужен вызывающему коду
МетодКогда выбиратьЧто возвращаетЧего не делает
Promise.allНужны все значения, и один отказ делает результат непригоднымМассив значений по позициям или первая причина отказа aggregateНе отменяет остальные операции
Promise.allSettledКаждый вход нужно учесть независимо от его исходаМассив статусов всех входов после их завершенияНе отменяет и не исправляет побочные эффекты
Promise.raceНужен первый завершившийся исход, например гонка с таймеромЗначение или ошибка первого settled promiseТаймером нельзя отменить проигравшую работу
Promise.anyДостаточно первого успешного результатаПервое fulfilled-значение или AggregateError, если все отказалиНе останавливает остальные попытки
\n

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

\n

Как добавить отмену для fetch

\n

Отмена должна иметь владельца и канал связи. В браузерном Fetch API таким каналом служит AbortSignal: вызывающий код создаёт AbortController, передаёт его сигнал каждой поддерживаемой операции и вызывает abort(), когда группа больше не нужна. Сам Promise.all при этом остаётся только агрегатором.

\n
async function fetchJson(url, signal) {\n  const response = await fetch(url, { signal });\n  if (!response.ok) {\n    throw new Error(url + ': HTTP ' + response.status);\n  }\n  return response.json();\n}\n\nasync function loadProfile(urls) {\n  const controller = new AbortController();\n  const requests = urls.map((url) => fetchJson(url, controller.signal));\n\n  try {\n    return await Promise.all(requests);\n  } catch (error) {\n    controller.abort();\n    throw error;\n  }\n}\n\ntry {\n  const [settings, limits, history] = await loadProfile([\n    'https://api.example.test/settings',\n    'https://api.example.test/limits',\n    'https://api.example.test/history',\n  ]);\n  console.log({ settings, limits, history });\n} catch (error) {\n  console.error('profile failed:', error.message);\n}
\n

В примере ошибка одного fetch попадает в catch, после чего контроллер посылает сигнал всем трём запросам. Поддерживаемый браузером запрос обычно завершается отказом, связанным с abort. Между исходной ошибкой и вызовом abort() есть короткая гонка: операция могла уже завершиться, поэтому код не должен обещать, что каждый запрос будет остановлен.

\n

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

\n

Где AbortController не спасает

\n

AbortController работает только там, где операция действительно слушает переданный сигнал. Он не прерывает синхронный цикл, обычный promise с игнорируемым аргументом, уже отправленную сервером команду или запись, которая успела зафиксироваться до отмены. Для базы данных, очереди и внешнего сервиса нужен их собственный протокол: отмена запроса, дедлайн, идемпотентный ключ или компенсационное действие.

\n

Отмена также не равна откату. Клиент может прекратить ждать тело ответа, а сервер уже успеть списать деньги, создать задачу или записать событие. Поэтому границу побочных эффектов проверяют на стороне исполнителя. Для повторных запусков добавляют идемпотентность и корреляционный идентификатор; для диагностики логируют начало, отмену, успешное завершение и причину отказа каждой операции.

\n
async function cancellableStep(signal) {\n  if (signal.aborted) {\n    throw signal.reason ?? new Error('cancelled before start');\n  }\n\n  return new Promise((resolve, reject) => {\n    const timer = setTimeout(() => resolve('done'), 100);\n    signal.addEventListener('abort', () => {\n      clearTimeout(timer);\n      reject(signal.reason ?? new Error('cancelled'));\n    }, { once: true });\n  });\n}\n\nconst controller = new AbortController();\nconst work = cancellableStep(controller.signal);\ncontroller.abort(new Error('caller no longer needs the result'));\n\nawait work.catch((error) => console.log(error.message));\n// caller no longer needs the result
\n

Этот фрагмент показывает минимальный контракт для собственной операции: она проверяет сигнал до старта, слушает событие во время ожидания и освобождает таймер при отмене. Для реальной работы нужно также снять обработчик после обычного завершения, закрыть ресурс и отдельно решить, что делать с уже выполненным побочным эффектом.

\n

Диагностика симптома в проекте

\n
Трассировка вместо предположения
НаблюдениеГипотезаПроверкаСледующее действие
Общий catch сработал, но поздний лог продолжаетсяДругой вход ещё выполняетсяДобавить started, fulfilled, rejected и cancelled с request idРазделить исход aggregate и жизненный цикл операции
Результаты «перепутались»Сопоставили скорость завершения с позицией массиваВывести имя входа и его индексЧитать массив по позиции или возвращать объект с ключом
Видна только одна ошибкаВыбран fail-fast отчётПроверить, нужен ли полный список статусовДля независимых входов рассмотреть allSettled
После отказа лишние запросы расходуют ресурсОперации не получили общий сигналПроверить сигнатуру API и тест отменыПередать AbortSignal или реализовать собственный cancel contract
После abort данные всё равно изменилисьСерверный эффект уже произошёлСверить корреляционный id с журналом исполнителяДобавить идемпотентность или компенсацию, а не обещать откат
\n

Воспроизводимая проверка перед изменением кода

\n
  1. Перечислите входные promise и реальную операцию за каждым из них.
  2. Зафиксируйте требование: нужны все значения, все статусы, первый успех или только ограничение времени ожидания.
  3. Запустите детерминированную трассировку с одной ранней ошибкой и двумя поздними завершениями.
  4. Проверьте порядок массива отдельно от порядка логов: эти последовательности отвечают на разные вопросы.
  5. Если требуется остановка, найдите API отмены и передайте сигнал до старта операции.
  6. Напишите отрицательный тест: один вход отклоняется, а остальные либо получают отмену, либо честно продолжают работу по задокументированному контракту.
  7. Проверьте побочный эффект на стороне исполнителя. Клиентский отказ promise не доказывает, что сервер забыл уже принятую команду.
  8. Только после этого выберите Promise.all, Promise.allSettled, race или any.
\n

Границы применимости

\n

Promise — это модель будущего значения и его исхода, а не поток и не диспетчер ресурсов. Из самого факта совместного запуска нельзя вывести число потоков, порядок сетевых пакетов, освобождение соединения или нагрузку на сервер. Эти свойства определяются средой исполнения и конкретным API.

\n

Ранний отказ aggregate подходит, когда зависимый следующий шаг больше нельзя выполнять с неполным набором данных. Он не подходит как единственный механизм остановки дорогих независимых задач. allSettled полезен для отчёта о независимых результатах, но не превращает ошибки в успех и не скрывает необходимость очистки.

\n

Пример с fetch применим к операциям, которые принимают AbortSignal. Пример с собственной функцией применим только после того, как операция реально проверяет сигнал и освобождает свой ресурс. Для CPU-bound вычисления, транзакции и внешней очереди нужны отдельные ограничения и тесты.

\n

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

\n

Итоговая проверка проста: назовите promise, который отклоняет aggregate; назовите работу, которая может продолжиться; назовите владельца сигнала; покажите тест, подтверждающий остановку или её отсутствие. Если на последний вопрос отвечают только «сработал catch», в коде смешаны два разных контракта.

" }