Files
progcode/editorial/agent-rewrites/080.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

7 lines
16 KiB
JSON
Raw 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 управляет итогом набора промисов, но не владеет внешней работой. Разбираем порядок результатов, ранний reject, отдельную отмену и безопасную проверку на небольшом примере.",
"contentHtml": "<p>Сервис собирает профиль пользователя из трёх запросов: настройки, лимиты и историю операций. Один запрос завершается ошибкой. Обработчик сразу попадает в <code>catch</code> и возвращает ответ об ошибке. Через секунду другой запрос всё ещё пишет в лог, держит соединение или меняет локальное состояние.</p>\n<p>Симптом — разработчик видит отклонённый <code>Promise.all</code> и говорит: «набор остановился». Цена ошибки — лишняя нагрузка, гонка за общим состоянием и неверная очистка ресурсов. Код может повторить операцию, пока первый запуск ещё работает. Расследование усложняется: aggregate уже отклонён, а позднее событие живёт в другом promise.</p>\n<p>Тезис простой: <code>Promise.all</code> сообщает исход группы. Он не является командой отмены для входных promise и не знает, какая внешняя операция их породила. Ранний <code>reject</code> останавливает ожидание успешного aggregate, но не доказывает остановку работы. Для остановки нужен отдельный контракт: владелец сигнала, способ передать его операции и подтверждение результата.</p>\n<h2>Механизм: три разных объекта</h2>\n<p>Первый объект — входной promise. Он представляет один будущий исход: значение или ошибку. Второй — aggregate promise, который возвращает <code>Promise.all(iterable)</code>. Он собирает значения по позиции входного iterable и принимает решение о собственном исходе. Третий — внешняя операция: HTTP-запрос, чтение файла, запрос к базе или вычисление. Она может существовать за пределами promise-модели.</p>\n<p>Связь направлена только в одну сторону. Вход сообщает aggregate, что он fulfilled или rejected. Aggregate сообщает вызывающему коду свой исход. Такая связь не содержит команды «остановись» для другого входа. Даже после reject aggregate другой вход может позже fulfilled или rejected.</p>\n<pre><code>const left = Promise.reject(new Error('parse failed'));\nconst right = new Promise((resolve) =&gt; {\n setTimeout(() =&gt; resolve('right is done'), 100);\n});\n\ntry {\n await Promise.all([left, right]);\n} catch (error) {\n console.log('aggregate rejected:', error.message);\n}\n\n// Это не команда отмены для right.</code></pre>\n<p>Здесь <code>Promise.all</code> отклоняется из-за <code>left</code>. Ожидание текущей функции заканчивается. Но таймер получил отдельное право завершить <code>right</code>. Никакой код не передал ему сигнал отмены. Поэтому позднее значение появится, даже если вызывающий код больше его не читает.</p>\n<p>Это учебный пример с фиксированными promise и таймером. Он не измеряет задержку сети, поведение браузера или расход ресурсов в production. Он проверяет только границу между исходом aggregate и исходом другого входа.</p>\n<figure><img src='/assets/editorial/2025/teaching-engineering-2025-misconception-matrix.svg' alt='Три уровня Promise.all: входной promise, aggregate promise и внешняя операция' loading='lazy' /><figcaption>Aggregate фиксирует свой исход. Из этого факта нельзя автоматически вывести отмену входа или внешней операции.</figcaption></figure>\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>Добавить trace для каждого входа</td><td>Не называть aggregate отменой; найти владельца</td></tr><tr><td>Значения пришли в неожиданном порядке</td><td>Ожидалась скорость, а не позиция iterable</td><td>Сравнить индексы и порядок событий</td><td>Читать результат по позиции или хранить ключ</td></tr><tr><td>Нужно увидеть каждую ошибку, но видна одна</td><td><code>Promise.all</code> завершает aggregate на первом reject</td><td>Проверить требование к полному отчёту</td><td>Рассмотреть <code>Promise.allSettled</code></td></tr><tr><td>После ошибки запросы продолжаются</td><td>Операции не получили общий сигнал</td><td>Проверить API и обработчик сигнала</td><td>Передать <code>AbortSignal</code> или другой cancel contract</td></tr><tr><td>Остановка считается доказанной по reject</td><td>Смешаны исход результата и управление ресурсом</td><td>Назвать реальное действие прерывания</td><td>Разделить зависимую логику и протокол остановки</td></tr></tbody></table>\n<h2>Порядок результатов и первая ошибка</h2>\n<p>При успешном исходе массив значений сохраняет порядок входного iterable, а не порядок завершения. Быстрый второй promise всё равно окажется во второй позиции. Это удобно для сопоставления, пока массив не меняется между запуском и чтением.</p>\n<p>При reject aggregate получает ошибку входа, который первым сообщил отклонение в рамках алгоритма наблюдения. Это не отчёт обо всех ошибках и не журнал времени завершения. <code>Promise.allSettled</code> решает другую задачу: ждёт завершения всех входов и возвращает статус каждого. Он тоже не добавляет отмену.</p>\n<h2>Отмена живёт рядом, но отдельно</h2>\n<p>Для операции, которая умеет принимать сигнал, отмену можно связать с обработкой aggregate. В браузерном или серверном коде это часто <code>AbortController</code> и его <code>signal</code>. Контроллер создаёт вызывающий код. Операции получают сигнал. При ошибке вызывающий код вызывает <code>abort()</code>. Конкретный API должен обработать сигнал по своему контракту.</p>\n<pre><code>async function loadPages(urls) {\n const controller = new AbortController();\n const signal = controller.signal;\n\n try {\n return await Promise.all(\n urls.map((url) =&gt; fetch(url, { signal }).then((response) =&gt; {\n if (!response.ok) throw new Error('HTTP ' + response.status);\n return response.json();\n })),\n );\n } catch (error) {\n controller.abort();\n throw error;\n }\n}</code></pre>\n<p>Фрагмент ограничен операциями <code>fetch</code>, которые получили сигнал. Он не отменяет синхронную функцию, уже записанную транзакцию или promise, который игнорирует <code>signal</code>. <code>abort()</code> означает запрос на остановку поддерживаемой операции, а не откат каждого побочного эффекта.</p>\n<h2>Проверка на маленькой трассировке</h2>\n<p>Запишите события каждого уровня. Первый promise отклоняет aggregate. Затем ручной владелец второго promise вызывает сохранённый <code>resolve</code>. Ожидаемая последовательность показывает два факта: aggregate rejected, а другой вход позже fulfilled.</p>\n<pre><code>const trace = [];\nlet finishRight;\n\nconst left = Promise.reject(new Error('left failed'));\nconst right = new Promise((resolve) =&gt; {\n finishRight = () =&gt; {\n trace.push('right fulfilled');\n resolve('ok');\n };\n});\n\nawait Promise.all([left, right]).catch(() =&gt; trace.push('aggregate rejected'));\nfinishRight();\nawait right;\n\nconsole.log(trace);\n// ['aggregate rejected', 'right fulfilled']</code></pre>\n<p>Трассировка не доказывает, что реальный запрос продолжился: в ней нет запроса. Она доказывает более узкое утверждение о promise, которым мы управляем вручную. Для сетевой отмены нужен отдельный тест API, который принимает сигнал, и проверка его наблюдаемого завершения.</p>\n<h2>Порядок действий</h2>\n<ol><li>Назовите каждый вход и внешнюю операцию, которая его создаёт.</li><li>Запишите нужный результат: все значения, первый успешный, все статусы или раннее завершение зависимой логики.</li><li>Снимите отдельный trace для входов и aggregate.</li><li>Проверьте отрицательный путь: после первого reject другой вход может завершиться позже.</li><li>Если нужна остановка, найдите API отмены и его владельца. Передайте сигнал до запуска.</li><li>Проверьте, что обработчик сигнала меняет состояние нужной операции.</li><li>Только затем выбирайте <code>Promise.all</code>, <code>Promise.allSettled</code> или другой способ композиции.</li></ol>\n<h2>Ограничения модели</h2>\n<p><code>Promise.all</code> не обещает параллельное выполнение в смысле потоков. JavaScript может передать управление между асинхронными продолжениями, а конкретный API сам определяет выполнение. Нельзя выводить пропускную способность, порядок сетевых пакетов или освобождение ресурса из одного вызова combinator.</p>\n<p>Нельзя считать <code>abort()</code> универсальным откатом. Он не возвращает отправленные данные, не отменяет побочный эффект на сервере и не исправляет уже завершённую запись. Он также не заменяет дедупликацию, идемпотентность и таймаут.</p>\n<p>Нельзя выбирать <code>allSettled</code> только потому, что первая ошибка неудобна. Если следующий шаг требует всех значений, ранний reject не даёт начать неполный расчёт. Если нужен отчёт о каждом независимом входе, подходит all-settled-семантика. Решение зависит от зависимости между работами.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Разбор готов, когда команда отвечает на четыре вопроса: какой promise отклоняет aggregate; какой вход может завершиться позже; какое действие останавливает внешнюю работу; какое наблюдение подтверждает остановку. Учебный код готов, если trace показывает независимые исходы. Код с реальными операциями готов, если тест проверяет поддержку сигнала и отрицательный путь, где один вход отвергнут, а другой ещё выполняется.</p>\n<p>Если на вопрос об остановке отвечают «<code>Promise.all</code>», модель не готова. Добавьте владельца отмены или честно зафиксируйте, что операция не поддерживает остановку.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://262.ecma-international.org/16.0/#sec-promise.all' target='_blank' rel='noopener noreferrer'>ECMA-262, ECMAScript 2025: Promise.all</a> — нормативный алгоритм композиции promise; он описывает исход aggregate и не задаёт отмену внешних ресурсов.</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> — порядок значений и отклонение aggregate при ошибке входа.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management#automatically_cancelling_in-progress_requests' target='_blank' rel='noopener noreferrer'>MDN: Automatically cancelling in-progress requests</a> — пример отдельного <code>AbortController</code> для отмены поддерживаемых <code>fetch</code>-запросов.</li></ul>"
}