Files

8 lines
23 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 79,
"slug": "editorial-2025-10-field-teaching-engineering",
"title": "Promise.all не отменяет работу: как не потерять второй запрос",
"excerpt": "Promise.all объединяет результаты, но не останавливает уже начатые операции. На коротком примере разберём first rejection, порядок значений, allSettled и явную отмену через AbortController.",
"contentHtml": "<p>Симптом появляется в момент первой ошибки. Код ждёт несколько операций через <code>await Promise.all(tasks)</code>, одна операция отклоняется, обработчик сразу переходит в <code>catch</code>, а автор считает остальные операции остановленными. Позже выясняется, что один запрос всё ещё пишет данные, таймер всё ещё выполняется, а зависимая логика уже очистила состояние. Цена ошибки — повторные записи, лишние запросы и расследование, в котором приходится восстанавливать границу ответственности по логам.</p>\n<p>Тезис простой: <code>Promise.all</code> объединяет наблюдаемые исходы promises, но не является протоколом отмены работы. Он сообщает результат aggregate promise. Он не получает автоматически право остановить операцию, которая создала входной promise. Чтобы объяснить такой код без ложной гарантии, нужно разделить три объекта: входной promise, aggregate promise и внешнюю работу.</p>\n<h2>Сначала назовите задачу</h2>\n<p>Рецепт становится опасным, когда его показывают раньше задачи. Возьмём учебный сценарий: нужно получить профиль и настройки, а затем построить экран. Если любой результат недоступен, экран строить нельзя. Здесь <code>Promise.all</code> подходит как условие для зависимого шага: зависимый код запускается только после успешного исхода двух входов.</p>\n<p>Но это условие не отвечает на другой вопрос: что делать с операцией, которая уже началась и ещё не завершилась? Объединение результатов и остановка работы относятся к разным контрактам. Первый задаёт JavaScript. Второй должен задавать приложение, библиотека или владелец ресурса.</p>\n<figure><img src=\"/assets/editorial/2019/event-loop-trace-2019.svg\" alt=\"Схема диагностики порядка асинхронных callback: от отметок времени и номера запуска к разделению sync, microtask и task и проверке trace\" loading=\"lazy\" /><figcaption>Для расследования разделяйте события aggregate и каждого входа: запись об ошибке общего ожидания ещё не показывает, что внешняя операция остановилась. Рисунок помогает найти источник и порядок callback, но не заменяет проверку API отмены.</figcaption></figure>\n<h2>Механизм: два исхода вместо одного</h2>\n<p>Пусть в <code>Promise.all</code> переданы <code>profilePromise</code> и <code>settingsPromise</code>. JavaScript создаёт новый aggregate promise. Он выполнится успешно, если все входы выполнятся. Значения попадут в массив в порядке входного iterable, а не в порядке завершения. Если один вход отклонится, aggregate promise отклонится с первой причиной отказа.</p>\n<p>Это не означает, что второй вход получил команду отмены. Второй promise может уже завершиться, продолжить ожидание или скрывать за собой работу, которую можно прервать только отдельным API. Вызов <code>catch</code> наблюдает отказ aggregate. Сам по себе он не меняет жизненный цикл запроса, чтения файла, вычисления или записи.</p>\n<p>Отсюда следует отрицательный путь. Если профиль отклонился, нельзя выводить «настройки отменены». Можно вывести только «общий результат недоступен с такой причиной». Дальше код обязан использовать собственный контракт: сигнал отмены, закрытие ресурса, остановку очереди, компенсацию или безопасное ожидание остатка. Если такого контракта нет, честный ответ — отмена не определена.</p>\n<h2>Минимальный контрпример</h2>\n<p>Ниже намеренно узкий пример. Он не обращается к сети, диску, часам или данным пользователей. Один вход отклоняется сразу. Второй вход удерживается до явного вызова <code>finishRemaining</code>. Это позволяет увидеть порядок событий и не приписывать JavaScript поведение, которого в коде нет.</p>\n<pre><code>let finishRemaining;\nconst remaining = new Promise((resolve) =&gt; {\n finishRemaining = () =&gt; resolve('settings');\n});\n\nconst combined = Promise.all([\n Promise.reject(new Error('profile failed')),\n remaining,\n]).catch(() =&gt; 'aggregate handled');\n\nawait combined;\nconsole.log('aggregate rejected');\nfinishRemaining();\nconsole.log(await remaining);\n// aggregate rejected\n// settings</code></pre>\n<p>После первой строки вывода aggregate уже обработал отказ. Но второй promise ещё существует. Функция <code>finishRemaining</code> завершает его позже. Пример доказывает только это: rejected aggregate не равен отмене каждого входа. Он не доказывает, как поведёт себя HTTP-клиент, база данных или очередь сообщений. Для каждого такого ресурса нужна отдельная документация и отдельный тест.</p>\n<p>Контрпример полезнее общей фразы «promises выполняются параллельно». Это слово слишком широкое. В JavaScript promise представляет состояние будущего результата; он не является универсальным дескриптором процесса, который можно остановить одним методом. Операции могут стартовать до создания aggregate, а их остановка может быть невозможна или требовать согласия внешнего владельца.</p>\n<h2>Три уровня, которые нельзя смешивать</h2>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td><code>catch</code> сработал, но запрос продолжился</td><td>Aggregate наблюдает отказ, а клиент запроса не получил сигнал отмены</td><td>Посмотреть API запроса и его обработчик сигнала</td><td>Добавить явный cancel-контракт или признать работу неотменяемой</td></tr><tr><td>Результаты приходят в неожиданном порядке</td><td>Порядок завершения перепутан с порядком iterable</td><td>Сравнить индексы входов и trace завершения</td><td>Читать массив по исходным индексам, а не по времени</td></tr><tr><td>После одной ошибки обработчик пишет частичный результат</td><td>Зависимый шаг запускается не только после fulfilled aggregate</td><td>Проверить место вызова и ветки после <code>catch</code></td><td>Разделить partial result и готовый aggregate</td></tr><tr><td>Заменили <code>all</code> на <code>allSettled</code>, но проблема осталась</td><td>Нужен был протокол остановки, а изменили только форму отчёта</td><td>Назвать требование: все исходы или остановка работы</td><td>Выбрать combinator для отчёта и отдельно спроектировать отмену</td></tr><tr><td>Текст обещает «остановить всё»</td><td>Рецепт подменил причинную модель</td><td>Попросить показать объект, который посылает cancel</td><td>Сузить утверждение до aggregate и добавить отрицательный путь</td></tr></tbody></table>\n<h2>Как объяснить код без лишней теории</h2>\n<p>Начните с одной проверяемой фразы: «Экран строится только после двух успешных результатов». Затем назовите aggregate promise и покажите, где читается его результат. После этого добавьте отказ одного входа. Читатель должен увидеть, что зависимый шаг не запускается. Только теперь задайте вопрос о втором входе.</p>\n<p>Ответ должен быть конкретным. Если второй вход уже создан, у него есть собственное состояние. <code>Promise.all</code> не предоставляет в этом вызове метода <code>cancel</code>. Если вход связан с <code>fetch</code>, автор может передать <code>AbortSignal</code> и вызвать <code>AbortController.abort()</code>. Но это уже договор Fetch и конкретного кода приложения, а не свойство <code>Promise.all</code>. Даже сигнал не превращает любую серверную операцию в гарантированно отменённую: сервер мог принять запрос, а клиент мог лишь прекратить ожидание ответа.</p>\n<p>Такой ответ не должен звучать как универсальный рецепт отмены. Для учебного фрагмента достаточно показать место ответственности. В production нужно проверить, что делает клиент после abort, что происходит на сервере, как закрывается ресурс и допустима ли повторная попытка. Если эти условия не описаны, статья должна остановиться на границе знания.</p>\n<h2>Проверяемый пример с AbortController</h2>\n<p>Если входами являются <code>fetch</code>-запросы, общий сигнал делает отмену явной. Сохраните следующий фрагмент как <code>load-pages.mjs</code> и запустите на Node.js 18 или новее командой <code>node load-pages.mjs</code>. URL-ы должны быть доступны вашему тестовому серверу: один endpoint верните с ошибкой, другой задержите.</p>\n<pre><code>async function loadPages(urls) {\n const controller = new AbortController();\n const { signal } = controller;\n\n try {\n return await Promise.all(\n urls.map(async (url) =&gt; {\n const response = await fetch(url, { signal });\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${url}`);\n }\n return response.json();\n }),\n );\n } catch (error) {\n controller.abort();\n throw error;\n }\n}</code></pre>\n<p>Этот код отменяет только pending <code>fetch</code>, которым передан тот же сигнал. Он не откатывает данные, которые сервер уже принял, и не влияет на операцию, игнорирующую <code>signal</code>. Поэтому тест должен проверять две вещи: второй клиентский запрос получил abort, а серверная команда не требует повторения без идемпотентного ключа.</p>\n<p>Для полностью детерминированной проверки границы сети не нужно симулировать production-сервис. Поднимите тестовый endpoint с управляемой задержкой, добавьте в лог request id и сравните время <code>abort()</code> с серверным trace. Если сервер успел выполнить побочный эффект, результатом проверки будет не «всё отменено», а зафиксированное правило компенсации или сверки.</p>\n<h2>Когда нужен другой combinator</h2>\n<p><code>Promise.all</code> выбирают, когда нужен общий успех всех входов и ранний отказ aggregate при первой ошибке. <code>Promise.allSettled</code> выбирают, когда нужно дождаться и сохранить статус каждого входа. Это разные требования. Переключение на <code>allSettled</code> не отменяет операции и не исправляет частичную запись.</p>\n<p>Например, экран может показывать независимые виджеты. Тогда полезно получить массив состояний и отрисовать ошибку только у одного виджета. Но платёжный сценарий, в котором нельзя продолжать без обязательного ответа, требует другой проверки: dependent action не должна начаться после rejected aggregate. Если же один вызов уже создал побочный эффект, combinator не решает вопрос компенсации. Его должен решить доменный контракт.</p>\n<p>Не стоит заменять <code>Promise.all</code> на последовательный <code>await</code> только ради иллюзии контроля. Последовательный запуск уменьшает число одновременно начатых операций, но не отменяет первую операцию при ошибке второй. Он также меняет задержку и нагрузку. Сначала зафиксируйте требование, затем выбирайте форму ожидания.</p>\n<h2>Проверяемый порядок действий</h2>\n<ol><li>Запишите исходную задачу одним предложением: какие результаты нужны и какой шаг зависит от них.</li><li>Назовите каждый входной promise и работу, которая стоит за ним. Не называйте promise самой работой.</li><li>Покажите fulfilled-путь: все входы завершились, aggregate вернул значения в порядке iterable, зависимый шаг получил полный набор.</li><li>Покажите отрицательный путь: один вход отклонился, aggregate отклонился, зависимый шаг не стартовал.</li><li>Проверьте оставшийся вход отдельным наблюдением. Ответьте, может ли он завершиться после reject и кто имеет право его остановить.</li><li>Если нужна отмена, найдите реальный контракт ресурса: сигнал, close, cancel, rollback или другой механизм подтверждения.</li><li>Проверьте частичные результаты и побочные эффекты. Отдельно решите, допустимы ли повтор, компенсация и повторный запуск.</li><li>Сформулируйте готовность одной проверяемой фразой и приложите тест для положительной и отрицательной ветки.</li></ol>\n<h2>Ограничения учебного примера</h2>\n<p>Пример выше фиксирует порядок наблюдений в памяти. Он не моделирует latency, сетевые разрывы, retry, таймауты, серверную обработку, блокировки базы, очередь или пользовательскую сессию. Нельзя переносить его вывод как готовую архитектуру. Его задача уже: показать, почему aggregate rejection не доказывает отмену входа.</p>\n<p>Есть и другой отрицательный путь: остановка может быть технически доступна, но логически запрещена. Например, сервер уже принял команду, и отмена клиентского ожидания не должна приводить к повторной команде без idempotency key. В таком случае «прервать ожидание» и «отменить побочный эффект» — два разных решения. Текст, который объединяет их одним словом «cancel», скрывает риск.</p>\n<p>Не заявляйте production-эффект по одному примеру. Проверка должна установить, что aggregate, входной promise и cancel-контракт связаны наблюдаемыми событиями. Она не доказывает безопасность любого асинхронного pipeline; для этого нужен отдельный тест конкретной системы.</p>\n<h2>Критерий готовности</h2>\n<p>Объяснение готово, если независимый читатель может без подсказки ответить на четыре вопроса: какой результат объединяет <code>Promise.all</code>; что происходит при первом reject; может ли оставшийся вход закончиться позже; кто именно останавливает внешнюю работу. Код должен проходить тесты для fulfilled-пути и для отказа, а текст — не обещать отмену там, где в API нет такого контракта.</p>\n<p>Практическая финальная проверка короткая. Уберите названия методов и попросите восстановить модель по событиям. Затем верните код и сравните каждое утверждение с наблюдаемым результатом. Если для фразы «всё остановилось» нельзя назвать объект, который отправил сигнал остановки, замените её на точное утверждение об aggregate. Такой порядок сохраняет полезный рецепт и не переносит его за пределы условий задачи.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://ecma-international.org/wp-content/uploads/ECMA-262_16th_edition_june_2025.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Ecma International: ECMAScript 2025, 16th edition, June 2025</a> — нормативная спецификация; алгоритм <code>Promise.all</code> описывает aggregate outcome, входной iterable и обработку отказа, но не задаёт отмену внешних ресурсов.</li><li><a href=\"https://github.com/mdn/content/blob/3fad0447b4901e28fe88769976787d8d8b87d66d/files/en-us/web/javascript/reference/global_objects/promise/all/index.md?plain=1\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: Promise.all()</a> — справка о fulfilled/rejected, порядке значений, fail-fast, обработанных входах и различии с <code>Promise.allSettled()</code>.</li><li><a href=\"https://github.com/mdn/content/blob/3fad0447b4901e28fe88769976787d8d8b87d66d/files/en-us/web/api/abortcontroller/index.md?plain=1\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: AbortSignal</a> — контракт передачи сигнала в поддерживающую операцию, вызова <code>AbortController.abort()</code> и результата abort для Fetch.</li></ul>"
}