{ "index": 80, "slug": "editorial-2025-10-mechanism-teaching-engineering", "title": "Promise.all не отменяет работу: как разделить результат и управление", "excerpt": "Разбираем на трассировке, что именно отклоняет Promise.all, почему соседние операции продолжаются и как связать отмену с AbortController, не выдавая запрос на остановку за откат побочных эффектов.", "contentHtml": "
Сервис собирает профиль из настроек, лимитов и истории операций. Код запускает три запроса через Promise.all. Если настройки отвечают ошибкой, обработчик сразу получает отказ и возвращает клиенту ошибку. В это время история может ещё выполняться: соединение занято, лог продолжает поступать, а завершившийся запрос может попытаться обновить общее состояние.
Такой симптом часто описывают словами «Promise.all остановил набор». Это неточное объяснение. Если его принять за контракт отмены, команда начнёт повторять сбор профиля поверх ещё работающего запуска или решит, что серверный побочный эффект уже невозможен. Правильный вопрос звучит иначе: какой объект сообщил об ошибке и каким механизмом владелец работы может остановить саму работу?
В этой ситуации есть три разных объекта. Входной promise представляет один будущий исход: значение или ошибку. Aggregate promise, который вернул Promise.all, представляет решение о всей группе. Внешняя операция — сетевой запрос, чтение файла, обращение к базе или вычисление — создаётся конкретным API и может иметь собственный жизненный цикл.
Promise.all связывает исходы первых двух уровней. Он подписывается на входы, ждёт их успешного завершения и кладёт значения в массив по позиции входного iterable. Первый отказ переводит aggregate в состояние rejected. В этом алгоритме нет универсальной команды, которая должна остановить остальные входы или ресурс, породивший их.
У метода есть две полезные и проверяемые гарантии. При успехе он возвращает массив значений в порядке входного iterable, даже если второй запрос завершился раньше первого. При отказе он отклоняет возвращённый promise с причиной отказа входа, который первым отклонился. После этого ожидание aggregate заканчивается, но остальные входы всё ещё могут перейти в свой исход.
\nСлово «первым» относится к моменту, когда отказ наблюдён для aggregate, а не к позиции элемента и не к полному отчёту о всех ошибках. Последующие входы всё равно получают обработчики через внутреннюю композицию. Поэтому поздний отказ не обязан превращаться в отдельное необработанное исключение, а позднее выполнение не исчезает только потому, что вызывающий код перестал ждать aggregate.
\nconst 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 уже отклонён, когда два других входа ещё не завершились.
Это намеренно маленький тест. Таймер не моделирует TCP-соединение, запрос к базе или отмену на сервере. Он воспроизводит только границу между состоянием aggregate и состояниями уже запущенных входов.
\nРезультат успешного вызова сопоставляется с исходным массивом, а не со скоростью задач. Вызов Promise.all([loadSettings(), loadHistory()]) вернёт настройки в позиции 0, историю в позиции 1, даже если история пришла раньше. Это делает композицию удобной, пока список входов не меняется между запуском и чтением.
Если нужны статусы всех независимых операций, выбирайте Promise.allSettled. Он ждёт завершения каждого входа и возвращает элементы вида { status: 'fulfilled', value } или { status: 'rejected', reason }. Этот метод меняет форму отчёта, но не добавляет отмену: ни all, ни allSettled не знают, как остановить произвольную работу.
| Метод | Когда выбирать | Что возвращает | Чего не делает |
|---|---|---|---|
Promise.all | Нужны все значения, и один отказ делает результат непригодным | Массив значений по позициям или первая причина отказа aggregate | Не отменяет остальные операции |
Promise.allSettled | Каждый вход нужно учесть независимо от его исхода | Массив статусов всех входов после их завершения | Не отменяет и не исправляет побочные эффекты |
Promise.race | Нужен первый завершившийся исход, например гонка с таймером | Значение или ошибка первого settled promise | Таймером нельзя отменить проигравшую работу |
Promise.any | Достаточно первого успешного результата | Первое fulfilled-значение или AggregateError, если все отказали | Не останавливает остальные попытки |
Например, таймаут через Promise.race меняет то, что увидит вызывающий код, но сам по себе не выключает медленный запрос. Если запрос дорогой или меняет данные, таймаут должен идти вместе с реальным контрактом отмены либо с отдельной защитой от повторной операции.
Отмена должна иметь владельца и канал связи. В браузерном Fetch API таким каналом служит AbortSignal: вызывающий код создаёт AbortController, передаёт его сигнал каждой поддерживаемой операции и вызывает abort(), когда группа больше не нужна. Сам Promise.all при этом остаётся только агрегатором.
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() есть короткая гонка: операция могла уже завершиться, поэтому код не должен обещать, что каждый запрос будет остановлен.
В прикладном коде контроллер часто принадлежит экрану, обработчику запроса или задаче верхнего уровня. Тогда функция принимает внешний signal, а не создаёт скрытый контроллер, чтобы закрытие страницы или отмена родительской задачи тоже дошли до fetch. Контракт следует описать явно: кто вызывает abort(), какие API принимают сигнал и какое состояние видит вызывающий код после отмены.
AbortController работает только там, где операция действительно слушает переданный сигнал. Он не прерывает синхронный цикл, обычный promise с игнорируемым аргументом, уже отправленную сервером команду или запись, которая успела зафиксироваться до отмены. Для базы данных, очереди и внешнего сервиса нужен их собственный протокол: отмена запроса, дедлайн, идемпотентный ключ или компенсационное действие.
Отмена также не равна откату. Клиент может прекратить ждать тело ответа, а сервер уже успеть списать деньги, создать задачу или записать событие. Поэтому границу побочных эффектов проверяют на стороне исполнителя. Для повторных запусков добавляют идемпотентность и корреляционный идентификатор; для диагностики логируют начало, отмену, успешное завершение и причину отказа каждой операции.
\nasync 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| Наблюдение | Гипотеза | Проверка | Следующее действие |
|---|---|---|---|
Общий catch сработал, но поздний лог продолжается | Другой вход ещё выполняется | Добавить started, fulfilled, rejected и cancelled с request id | Разделить исход aggregate и жизненный цикл операции |
| Результаты «перепутались» | Сопоставили скорость завершения с позицией массива | Вывести имя входа и его индекс | Читать массив по позиции или возвращать объект с ключом |
| Видна только одна ошибка | Выбран fail-fast отчёт | Проверить, нужен ли полный список статусов | Для независимых входов рассмотреть allSettled |
| После отказа лишние запросы расходуют ресурс | Операции не получили общий сигнал | Проверить сигнатуру API и тест отмены | Передать AbortSignal или реализовать собственный cancel contract |
| После abort данные всё равно изменились | Серверный эффект уже произошёл | Сверить корреляционный id с журналом исполнителя | Добавить идемпотентность или компенсацию, а не обещать откат |
Promise.all, Promise.allSettled, race или any.Promise — это модель будущего значения и его исхода, а не поток и не диспетчер ресурсов. Из самого факта совместного запуска нельзя вывести число потоков, порядок сетевых пакетов, освобождение соединения или нагрузку на сервер. Эти свойства определяются средой исполнения и конкретным API.
\nРанний отказ aggregate подходит, когда зависимый следующий шаг больше нельзя выполнять с неполным набором данных. Он не подходит как единственный механизм остановки дорогих независимых задач. allSettled полезен для отчёта о независимых результатах, но не превращает ошибки в успех и не скрывает необходимость очистки.
Пример с fetch применим к операциям, которые принимают AbortSignal. Пример с собственной функцией применим только после того, как операция реально проверяет сигнал и освобождает свой ресурс. Для CPU-bound вычисления, транзакции и внешней очереди нужны отдельные ограничения и тесты.
allSettled.Итоговая проверка проста: назовите promise, который отклоняет aggregate; назовите работу, которая может продолжиться; назовите владельца сигнала; покажите тест, подтверждающий остановку или её отсутствие. Если на последний вопрос отвечают только «сработал catch», в коде смешаны два разных контракта.