{ "index": 81, "slug": "editorial-2025-10-practice-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", "excerpt": "Promise.all объединяет результаты асинхронных операций, но не управляет их остановкой. Разбираем модель, контрпример, проверку и отдельный контракт отмены.", "contentHtml": "
Симптом появляется после первой же ошибки: один запрос отклонился, обработчик получил исключение, а соседние запросы продолжают выполняться. В логах уже виден общий отказ, но база данных, сетевой клиент или внешний сервис ещё получают работу. Цена ошибки — лишний трафик, ненужные записи, гонки при очистке и неверное сообщение пользователю. Если эту границу объяснить неточно, команда начнёт искать отмену в месте, которое умеет только ждать.
\nТезис простой: Promise.all описывает исход группы promises, а не жизненный цикл операций, которые за ними стоят. Aggregate promise может отклониться на первой ошибке. Остальные входы при этом не получают приказ остановиться. Значит, ожидание и отмена — два разных контракта. Их нужно проектировать и проверять раздельно.
Представьте два входа: left и right. Каждый из них обещает значение в будущем. Вызов Promise.all([left, right]) создаёт третий promise. Он становится успешным, когда все входы успешны, и отклоняется, когда один вход отклоняется. При успехе значения идут в том же порядке, что и входы.
Третий promise не владеет внутренней работой left и right. Он наблюдает их состояния и сообщает общий результат. Если left завершился ошибкой, aggregate promise может завершиться сразу. Уже начатый right не обязан завершаться в тот же момент. Если right сам изменяет состояние внешней системы, наблюдение за ним не откатывает это изменение.
Учебный пример ниже не обращается к сети, файлам или базе. Он вручную завершает второй promise после того, как общий promise уже отклонился. Так видна только семантика ожидания. Пример не доказывает, что какой-либо реальный клиент продолжит работу именно с такой задержкой.
\nlet 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. Ожидание результата группы не стало командой отмены.
Важна и другая деталь. Promise.all не создаёт сами операции. К моменту вызова promises часто уже запущены: функция вернула promise, сетевой запрос уже отправлен, чтение уже поставлено в очередь. Обёртка видит результат этой работы, но не получает универсального доступа к её внутренним ресурсам.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После первой ошибки соседний запрос всё ещё виден в логах | Aggregate promise сообщает отказ, но не отменяет вход | Добавить отдельные метки старта и завершения каждой операции | Решить, нужна ли отмена, и передать сигнал в API операции |
| Код ловит общий отказ и считает работу законченной | Смешаны состояние aggregate promise и состояние ресурсов | Проверить, что происходит с каждым входом после catch | Дождаться нужных cleanup-действий или описать их отдельный контракт |
| Пользователь нажал «отмена», но запрос продолжился | Кнопка меняет интерфейс, а сигнал не дошёл до транспорта | Проверить прохождение AbortSignal до вызова API | Связать действие пользователя с поддерживаемым механизмом отмены |
| После ошибки меняется общий объект | Одна из операций имеет побочный эффект | Сравнить состояние до запуска, после отказа и после завершения остальных входов | Сделать операцию идемпотентной, добавить компенсацию или изменить порядок |
| В сообщении указано «все запросы остановлены» | Вывод сделан по первой ошибке, а не по наблюдению ресурсов | Найти подтверждение остановки для каждого участника группы | Сузить формулировку до «общий результат отклонён» |
Отмена появляется только там, где её поддерживает исполнитель. Для браузерного fetch обычно передают AbortSignal, полученный от AbortController. Контроллер не превращает любой promise в отменяемый. Он передаёт сигнал в конкретный API, а API решает, как остановить или прервать свою работу.
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 уже успел изменить серверное состояние, прекращение ожидания ответа не отменит это изменение. Для серверной операции нужен серверный контракт: ключ идемпотентности, отмена задания, транзакция или компенсационное действие.
Не следует использовать контроллер как универсальный откат. Он помогает остановить поддерживаемую передачу данных. Он не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную функцию, которая просто вернула promise.
\nПоложительный путь начинается с успешных входов. Тогда aggregate promise возвращает массив значений в порядке входного массива. Это удобно, когда зависимый код может начать работу только после получения всех значений. Проверка должна подтвердить не только наличие двух ответов, но и соответствие позиции: первый результат относится к первому входу.
\nОтрицательный путь начинается с отклонения одного входа. Aggregate promise сообщает ошибку, но у приложения остаются вопросы. Нужно ли дождаться остальных результатов? Нужно ли отменить их? Можно ли показать частичные данные? Нужна ли компенсация побочного эффекта? На эти вопросы Promise.all не отвечает. Если нужен итог каждого входа, включая ошибки, применяют Promise.allSettled. Если нужна остановка, добавляют API отмены. Если нужна повторная попытка, описывают её лимит и область действия отдельно.
Отдельно проверяйте поздние события. Отмена может прийти после ответа сервера. Таймаут может сработать после записи на сервере. Первый отказ может появиться раньше, чем второй запрос заметит закрытый сигнал. Обработчик должен различать «мы перестали ждать», «транспорт получил отмену» и «внешняя операция действительно прекратилась». Это три разных наблюдения.
\nЭта модель объясняет семантику группы promises, но не задаёт политику приложения. Она не решает, какой запрос важнее, как долго ждать, сколько раз повторять и как сообщать частичный результат. Для этих решений нужны отдельные правила и наблюдаемые сигналы.
\nОна также не заменяет документацию конкретной библиотеки. Один клиент может поддерживать AbortSignal, другой — собственный метод отмены, третий — только закрытие соединения. Нельзя переносить поведение одного транспорта на другой по одному имени promise.
Учебный код с ручным finishRight показывает порядок переходов в памяти. Он не является нагрузочным тестом, не измеряет задержки и не подтверждает поведение production-системы. В реальном сервисе проверяйте контракт библиотеки, логи транспорта и состояние внешнего ресурса.
Объяснение и код готовы, если читатель может ответить на четыре вопроса без догадки: какой promise сообщает общий результат, что происходит с остальными входами после первой ошибки, какой объект или API действительно принимает отмену и что происходит с уже выполненным побочным эффектом. В проверочном запуске должны быть видны отдельные события для aggregate promise и каждой операции. Для сценария отмены нужно подтвердить получение сигнала исполнителем, а для серверного изменения — отдельное подтверждение остановки или компенсации.
\nЕсли на вопрос об остановке отвечают только названием Promise.all, объяснение не готово. Добавьте контрпример и укажите владельца отмены. Если такого владельца нет, честный результат звучит так: общий promise отклонился, а начатая работа может продолжиться.
Promise.allSettled().