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": 80,
"slug": "editorial-2025-10-mechanism-teaching-engineering",
"title": "Promise.all не отменяет работу: как разделить результат и управление",
"excerpt": "Разбираем на трассировке, что именно отклоняет Promise.all, почему соседние операции продолжаются и как связать отмену с AbortController, не выдавая запрос на остановку за откат побочных эффектов.",
"contentHtml": "<p>Сервис собирает профиль из настроек, лимитов и истории операций. Код запускает три запроса через <code>Promise.all</code>. Если настройки отвечают ошибкой, обработчик сразу получает отказ и возвращает клиенту ошибку. В это время история может ещё выполняться: соединение занято, лог продолжает поступать, а завершившийся запрос может попытаться обновить общее состояние.</p>\n<p>Такой симптом часто описывают словами «<code>Promise.all</code> остановил набор». Это неточное объяснение. Если его принять за контракт отмены, команда начнёт повторять сбор профиля поверх ещё работающего запуска или решит, что серверный побочный эффект уже невозможен. Правильный вопрос звучит иначе: какой объект сообщил об ошибке и каким механизмом владелец работы может остановить саму работу?</p>\n<h2>Сначала разделим три уровня</h2>\n<p>В этой ситуации есть три разных объекта. Входной promise представляет один будущий исход: значение или ошибку. Aggregate promise, который вернул <code>Promise.all</code>, представляет решение о всей группе. Внешняя операция — сетевой запрос, чтение файла, обращение к базе или вычисление — создаётся конкретным API и может иметь собственный жизненный цикл.</p>\n<p><code>Promise.all</code> связывает исходы первых двух уровней. Он подписывается на входы, ждёт их успешного завершения и кладёт значения в массив по позиции входного iterable. Первый отказ переводит aggregate в состояние <code>rejected</code>. В этом алгоритме нет универсальной команды, которая должна остановить остальные входы или ресурс, породивший их.</p>\n<figure><img src='/assets/editorial/2025/teaching-engineering-2025-misconception-matrix.svg' alt='Матрица Promise.all: aggregate отклонён, отдельный вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены' loading='lazy' /><figcaption>Отклонённый aggregate — наблюдаемый исход композиции. Он не доказывает ни остановку входных promise, ни отмену внешней операции.</figcaption></figure>\n<h2>Что гарантирует Promise.all</h2>\n<p>У метода есть две полезные и проверяемые гарантии. При успехе он возвращает массив значений в порядке входного iterable, даже если второй запрос завершился раньше первого. При отказе он отклоняет возвращённый promise с причиной отказа входа, который первым отклонился. После этого ожидание aggregate заканчивается, но остальные входы всё ещё могут перейти в свой исход.</p>\n<p>Слово «первым» относится к моменту, когда отказ наблюдён для aggregate, а не к позиции элемента и не к полному отчёту о всех ошибках. Последующие входы всё равно получают обработчики через внутреннюю композицию. Поэтому поздний отказ не обязан превращаться в отдельное необработанное исключение, а позднее выполнение не исчезает только потому, что вызывающий код перестал ждать aggregate.</p>\n<pre><code>const trace = [];\nconst task = (name, delay, shouldReject = false) =&gt; new Promise((resolve, reject) =&gt; {\n setTimeout(() =&gt; {\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) =&gt; {\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']</code></pre>\n<p>Сохраните фрагмент в <code>promise-all-trace.mjs</code> и запустите командой <code>node promise-all-trace.mjs</code> в Node.js с поддержкой ES-модулей. Первый вывод появляется примерно через 10 мс, второй — после окончания всех таймеров. Точные интервалы зависят от планировщика, но порядок событий задаётся задержками: aggregate уже отклонён, когда два других входа ещё не завершились.</p>\n<p>Это намеренно маленький тест. Таймер не моделирует TCP-соединение, запрос к базе или отмену на сервере. Он воспроизводит только границу между состоянием aggregate и состояниями уже запущенных входов.</p>\n<h2>Порядок значений не равен порядку завершения</h2>\n<p>Результат успешного вызова сопоставляется с исходным массивом, а не со скоростью задач. Вызов <code>Promise.all([loadSettings(), loadHistory()])</code> вернёт настройки в позиции <code>0</code>, историю в позиции <code>1</code>, даже если история пришла раньше. Это делает композицию удобной, пока список входов не меняется между запуском и чтением.</p>\n<p>Если нужны статусы всех независимых операций, выбирайте <code>Promise.allSettled</code>. Он ждёт завершения каждого входа и возвращает элементы вида <code>{ status: 'fulfilled', value }</code> или <code>{ status: 'rejected', reason }</code>. Этот метод меняет форму отчёта, но не добавляет отмену: ни <code>all</code>, ни <code>allSettled</code> не знают, как остановить произвольную работу.</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>Promise.all</code></td><td>Нужны все значения, и один отказ делает результат непригодным</td><td>Массив значений по позициям или первая причина отказа aggregate</td><td>Не отменяет остальные операции</td></tr><tr><td><code>Promise.allSettled</code></td><td>Каждый вход нужно учесть независимо от его исхода</td><td>Массив статусов всех входов после их завершения</td><td>Не отменяет и не исправляет побочные эффекты</td></tr><tr><td><code>Promise.race</code></td><td>Нужен первый завершившийся исход, например гонка с таймером</td><td>Значение или ошибка первого settled promise</td><td>Таймером нельзя отменить проигравшую работу</td></tr><tr><td><code>Promise.any</code></td><td>Достаточно первого успешного результата</td><td>Первое fulfilled-значение или <code>AggregateError</code>, если все отказали</td><td>Не останавливает остальные попытки</td></tr></tbody></table>\n<p>Например, таймаут через <code>Promise.race</code> меняет то, что увидит вызывающий код, но сам по себе не выключает медленный запрос. Если запрос дорогой или меняет данные, таймаут должен идти вместе с реальным контрактом отмены либо с отдельной защитой от повторной операции.</p>\n<h2>Как добавить отмену для fetch</h2>\n<p>Отмена должна иметь владельца и канал связи. В браузерном Fetch API таким каналом служит <code>AbortSignal</code>: вызывающий код создаёт <code>AbortController</code>, передаёт его сигнал каждой поддерживаемой операции и вызывает <code>abort()</code>, когда группа больше не нужна. Сам <code>Promise.all</code> при этом остаётся только агрегатором.</p>\n<pre><code>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) =&gt; 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}</code></pre>\n<p>В примере ошибка одного <code>fetch</code> попадает в <code>catch</code>, после чего контроллер посылает сигнал всем трём запросам. Поддерживаемый браузером запрос обычно завершается отказом, связанным с abort. Между исходной ошибкой и вызовом <code>abort()</code> есть короткая гонка: операция могла уже завершиться, поэтому код не должен обещать, что каждый запрос будет остановлен.</p>\n<p>В прикладном коде контроллер часто принадлежит экрану, обработчику запроса или задаче верхнего уровня. Тогда функция принимает внешний <code>signal</code>, а не создаёт скрытый контроллер, чтобы закрытие страницы или отмена родительской задачи тоже дошли до <code>fetch</code>. Контракт следует описать явно: кто вызывает <code>abort()</code>, какие API принимают сигнал и какое состояние видит вызывающий код после отмены.</p>\n<h2>Где AbortController не спасает</h2>\n<p><code>AbortController</code> работает только там, где операция действительно слушает переданный сигнал. Он не прерывает синхронный цикл, обычный promise с игнорируемым аргументом, уже отправленную сервером команду или запись, которая успела зафиксироваться до отмены. Для базы данных, очереди и внешнего сервиса нужен их собственный протокол: отмена запроса, дедлайн, идемпотентный ключ или компенсационное действие.</p>\n<p>Отмена также не равна откату. Клиент может прекратить ждать тело ответа, а сервер уже успеть списать деньги, создать задачу или записать событие. Поэтому границу побочных эффектов проверяют на стороне исполнителя. Для повторных запусков добавляют идемпотентность и корреляционный идентификатор; для диагностики логируют начало, отмену, успешное завершение и причину отказа каждой операции.</p>\n<pre><code>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) =&gt; {\n const timer = setTimeout(() =&gt; resolve('done'), 100);\n signal.addEventListener('abort', () =&gt; {\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) =&gt; console.log(error.message));\n// caller no longer needs the result</code></pre>\n<p>Этот фрагмент показывает минимальный контракт для собственной операции: она проверяет сигнал до старта, слушает событие во время ожидания и освобождает таймер при отмене. Для реальной работы нужно также снять обработчик после обычного завершения, закрыть ресурс и отдельно решить, что делать с уже выполненным побочным эффектом.</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>Другой вход ещё выполняется</td><td>Добавить <code>started</code>, <code>fulfilled</code>, <code>rejected</code> и <code>cancelled</code> с request id</td><td>Разделить исход aggregate и жизненный цикл операции</td></tr><tr><td>Результаты «перепутались»</td><td>Сопоставили скорость завершения с позицией массива</td><td>Вывести имя входа и его индекс</td><td>Читать массив по позиции или возвращать объект с ключом</td></tr><tr><td>Видна только одна ошибка</td><td>Выбран fail-fast отчёт</td><td>Проверить, нужен ли полный список статусов</td><td>Для независимых входов рассмотреть <code>allSettled</code></td></tr><tr><td>После отказа лишние запросы расходуют ресурс</td><td>Операции не получили общий сигнал</td><td>Проверить сигнатуру API и тест отмены</td><td>Передать <code>AbortSignal</code> или реализовать собственный cancel contract</td></tr><tr><td>После abort данные всё равно изменились</td><td>Серверный эффект уже произошёл</td><td>Сверить корреляционный id с журналом исполнителя</td><td>Добавить идемпотентность или компенсацию, а не обещать откат</td></tr></tbody></table>\n<h2>Воспроизводимая проверка перед изменением кода</h2>\n<ol><li>Перечислите входные promise и реальную операцию за каждым из них.</li><li>Зафиксируйте требование: нужны все значения, все статусы, первый успех или только ограничение времени ожидания.</li><li>Запустите детерминированную трассировку с одной ранней ошибкой и двумя поздними завершениями.</li><li>Проверьте порядок массива отдельно от порядка логов: эти последовательности отвечают на разные вопросы.</li><li>Если требуется остановка, найдите API отмены и передайте сигнал до старта операции.</li><li>Напишите отрицательный тест: один вход отклоняется, а остальные либо получают отмену, либо честно продолжают работу по задокументированному контракту.</li><li>Проверьте побочный эффект на стороне исполнителя. Клиентский отказ promise не доказывает, что сервер забыл уже принятую команду.</li><li>Только после этого выберите <code>Promise.all</code>, <code>Promise.allSettled</code>, <code>race</code> или <code>any</code>.</li></ol>\n<h2>Границы применимости</h2>\n<p>Promise — это модель будущего значения и его исхода, а не поток и не диспетчер ресурсов. Из самого факта совместного запуска нельзя вывести число потоков, порядок сетевых пакетов, освобождение соединения или нагрузку на сервер. Эти свойства определяются средой исполнения и конкретным API.</p>\n<p>Ранний отказ aggregate подходит, когда зависимый следующий шаг больше нельзя выполнять с неполным набором данных. Он не подходит как единственный механизм остановки дорогих независимых задач. <code>allSettled</code> полезен для отчёта о независимых результатах, но не превращает ошибки в успех и не скрывает необходимость очистки.</p>\n<p>Пример с <code>fetch</code> применим к операциям, которые принимают <code>AbortSignal</code>. Пример с собственной функцией применим только после того, как операция реально проверяет сигнал и освобождает свой ресурс. Для CPU-bound вычисления, транзакции и внешней очереди нужны отдельные ограничения и тесты.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://tc39.es/ecma262/2025/multipage/control-abstraction-objects.html#sec-promise.all' target='_blank' rel='noopener noreferrer'>ECMAScript 2025 Language Specification: Promise.all</a> — нормативное описание успешного результата по позициям и отказа aggregate при отказе входа.</li><li><a href='https://tc39.es/ecma262/2025/multipage/control-abstraction-objects.html#sec-promise.allsettled' target='_blank' rel='noopener noreferrer'>ECMAScript 2025 Language Specification: Promise.allSettled</a> — нормативное описание ожидания всех входов и массива статусов.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all' target='_blank' rel='noopener noreferrer'>MDN: Promise.all()</a> — практическое описание порядка значений, fail-fast поведения и отличия от <code>allSettled</code>.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/API/AbortController' target='_blank' rel='noopener noreferrer'>MDN: AbortController</a> — контракт контроллера и сигнала для отмены поддерживаемых асинхронных операций.</li><li><a href='https://fetch.spec.whatwg.org/#abortcontroller-abort' target='_blank' rel='noopener noreferrer'>WHATWG Fetch Standard: abort()</a> — спецификация поведения abort в Fetch API.</li></ul>\n<p>Итоговая проверка проста: назовите promise, который отклоняет aggregate; назовите работу, которая может продолжиться; назовите владельца сигнала; покажите тест, подтверждающий остановку или её отсутствие. Если на последний вопрос отвечают только «сработал <code>catch</code>», в коде смешаны два разных контракта.</p>"
}