From 4eca4b88a5a5310bea72a00d1575c6394b3e03ba Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 14:39:15 +0300 Subject: [PATCH] editorial: revise articles 077-082 to 10/10 --- editorial/agent-rewrites/077.json | 4 ++-- editorial/agent-rewrites/078.json | 2 +- editorial/agent-rewrites/079.json | 6 +++--- editorial/agent-rewrites/080.json | 8 ++++---- editorial/agent-rewrites/081.json | 4 ++-- editorial/agent-rewrites/082.json | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/editorial/agent-rewrites/077.json b/editorial/agent-rewrites/077.json index d64b87a..f98f217 100644 --- a/editorial/agent-rewrites/077.json +++ b/editorial/agent-rewrites/077.json @@ -2,6 +2,6 @@ "index": 77, "slug": "editorial-2025-11-mechanism-research-method", "title": "Как проверить источник до того, как он повлияет на решение", - "excerpt": "ETag, дата публикации и цифровая подпись подтверждают разные свойства документа. Разбираем границы этих сигналов и собираем проверку, которая останавливает слишком сильный вывод.", - "contentHtml": "

Команда находит страницу с нужным API, видит свежую дату и сразу переносит совет в интеграцию. Через месяц endpoint ведёт себя иначе. Оказывается, страница описывала другой режим, а заголовок ETag приняли за номер релиза. Ошибка стоит дороже, чем время на чтение: меняется код, растёт число обходов, а причину трудно восстановить после инцидента.

\n

Тезис статьи прост: источник не равен доказательству решения. Проверка должна разделять публикацию, конкретное представление документа, наблюдаемый факт и действие, которое из него следует. Каждый переход требует своей опоры. Если опоры нет, проверка должна остановиться и показать, чего не хватает.

\n

Четыре уровня, которые нельзя смешивать

\n

Публикация — RFC, стандарт, релиз или датированная рекомендация. Она задаёт документ и его область. Представление — именно тот HTML, PDF или HTTP-ответ, который прочитал инженер. Оно может зависеть от языка, заголовков запроса, авторизации и времени.

\n

Наблюдение — короткая фраза, которую можно проверить в представлении: «в разделе указано условие X» или «ответ содержит заголовок ETag». Утверждение — уже интерпретация наблюдения. Решение — изменение кода, конфигурации или процесса. Страница может подтвердить наблюдение, но не обязана подтверждать решение для любого продукта.

\n
\"Матрица
Проверка движется от адресуемого представления к наблюдению, затем к ограниченному утверждению и только после этого к решению.
\n

Удобно хранить эти уровни раздельно. Для публикации нужен идентификатор и дата. Для представления — URL, версия, commit или снимок. Для наблюдения — точный раздел, строка или заголовок ответа. Для решения — условия применимости, риск и способ проверки.

\n

Почему ETag не заменяет версию

\n

В HTTP заголовок ETag служит validator выбранного представления ресурса. Клиент сравнивает значение при условных запросах. Сервер может вычислить его по содержимому, назначить внутренний идентификатор или учитывать согласование формата. Само значение не обязано раскрывать эту схему.

\n

Из ETag: \"a81c\" нельзя вывести, что документ относится к релизу 8.1, что он был опубликован в конкретную дату или что смысл каждого абзаца сохранится для другого endpoint. Last-Modified также говорит о времени изменения представления, а не о полном жизненном цикле продукта.

\n

Для исторической проверки нужен более сильный якорь: датированный RFC, тег релиза, commit, опубликованный PDF или сохранённый снимок. Validator полезно записать дополнительно. Он помогает повторить протокольную проверку, но не превращается в семантическую версию.

\n

Что подтверждает цифровая подпись

\n

Подпись помогает проверить целостность защищённого объекта и связь с подписантом. Она не превращает каждое утверждение внутри объекта в доказанный факт. Документ может быть подлинным и одновременно описывать ограниченный сценарий, старую конфигурацию или другой объект.

\n

Поэтому у записи проверки нужны две разные строки: integrity и claimEvidence. Первая отвечает на вопрос «документ не изменили после подписи?». Вторая — «есть ли в нём наблюдение, которое поддерживает именно нашу фразу?». Подмена одной строки другой создаёт ложную уверенность.

\n

Учебный пример: остановить слишком сильный вывод

\n
const record = {\n  source: {\n    url: 'https://example.test/api-guide',\n    etag: '\"a81c\"',\n    signature: 'valid'\n  },\n  observation: 'Документ описывает режим read-only для версии 2.',\n  claim: 'API безопасен для записи в любой версии.'\n};\n\nconst canRecommend =\n  Boolean(record.source.url) &&\n  Boolean(record.source.etag) &&\n  record.source.signature === 'valid' &&\n  record.claim.includes('версии 2') &&\n  record.observation.includes('версии 2');\n\nconsole.log(canRecommend); // false
\n

Это учебный пример, а не проверка реальной подписи и не production-код. У записи есть адрес, validator и валидная подпись. Но наблюдение ограничивает вывод версией 2 и режимом read-only, а утверждение расширяет его до записи и любой версии. Проверка возвращает отказ. Правильное действие — сузить утверждение или найти отдельное наблюдение для записи и нужной версии.

\n

Отрицательный путь важен не меньше успешного. Если система продолжает работу со статусом «достаточно» после отсутствия версии, locator или области применимости, она маскирует неизвестное. Средний балл доверия не исправит отсутствие конкретного факта.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок при чтении источника
СимптомПричинаПроверкаДействие
ETag выглядит как номер версииПротокольный validator смешали с релизомОткрыть правила версий издателя и сравнить с RFCСохранить ETag отдельно, найти release pin
Ссылка открывается, но текст уже другойЗафиксирован только текущий URLПроверить дату, commit, PDF или архивный снимокСузить исторический вывод или сменить источник
Подписанный документ подтверждает всё сразуЦелостность приняли за истинность тезисаРазделить signature и claim evidenceОставить только вывод об авторстве либо добавить факт
Документ верен, но совет не работаетScope документа не совпал с продуктомСопоставить endpoint, версию, права и режимДобавить условие применимости и отдельный тест
В отчёте нет причины остановкиОтказ заменили общим статусом доверияПроверить обязательные поля и отрицательные веткиВернуть статус hold с конкретным недостающим полем
\n

Порядок проверки

\n
  1. Сформулируйте решение одним предложением. Укажите действие, объект, версию и режим.
  2. Назовите уровень каждого утверждения: публикация, представление, наблюдение или решение.
  3. Закрепите представление. Используйте release, commit, датированный документ или снимок. Текущий URL оставьте только как навигацию.
  4. Запишите точный locator: раздел, строку, заголовок ответа или другой повторяемый фрагмент.
  5. Сверьте scope. Проверьте endpoint, версию, метод, права, язык, формат и условия, при которых сделано наблюдение.
  6. Сравните силу формулировки с фактом. Уберите слова «всегда», «безопасно», «поддерживает» и «для всех», если источник их не подтверждает.
  7. Проверьте отрицательный путь: уберите версию, измените режим или подставьте другой объект. Система должна остановиться, а не выдать прежний совет.
  8. Только после этого выберите действие и добавьте проверяемый критерий результата.
\n

Ограничения метода

\n

Эта модель не делает веб неизменяемым. Она не устанавливает истинность заявления одной ссылкой и не заменяет предметного эксперта. Официальный стандарт может не описывать operational-ограничение конкретного сервиса. Подпись может быть корректной, но относиться не к тому представлению. Архивный снимок фиксирует текст, но не доказывает, что система в тот день работала по нему.

\n

Метод также не требует сохранять весь интернет. Для дорогих решений достаточно закрепить минимальный набор: первичный документ, конкретное представление, наблюдение, область применимости и способ проверки. Если какой-то элемент нельзя получить, вывод должен стать уже. Отказ — нормальный результат, когда данных недостаточно.

\n

Критерий готовности

\n

Проверка готова, когда другой инженер может открыть тот же документ или его закреплённое представление, найти указанный locator, увидеть наблюдение и понять, почему оно поддерживает решение именно в заданном scope. Если он меняет версию, режим или объект, отрицательный путь возвращает остановку с понятной причиной. Ни один из этих результатов не требует доверять интонации автора.

\n

Проверяемые источники

\n" + "excerpt": "Дата, версия, HTTP-валидатор и цифровая подпись отвечают на разные вопросы. Разбираем, как связать источник с наблюдаемым фактом, не расширить его смысл и остановить неподтверждённое решение.", + "contentHtml": "

Команда находит страницу с нужным API и сразу переносит совет в интеграцию. В адресе есть знакомый номер, в ответе — ETag, внизу — свежая дата. Через месяц обновлённый endpoint ведёт себя иначе: номер был частью адреса документа, а ETag оказался валидатором представления, а не номером релиза. Ошибка уже превратилась в код, обходы и спор о том, какую страницу читали.

\n

Источник полезен только тогда, когда понятно, что именно он подтверждает. Для инженерного решения нужно разделить публикацию, конкретное представление, наблюдение, утверждение и действие. У каждого уровня своя граница. Если версия, локатор или область применимости неизвестны, правильный результат проверки — остановка и точное описание недостающего факта.

\n

Источник не равен доказательству

\n

Начинайте с формулировки решения, которое может измениться. «Документация про кеш» слишком широко. «Для GET-запроса к ресурсу X можно повторить условный запрос, если сервер прислал тот же валидатор» уже указывает объект, метод и условие. Теперь ясно, какой факт искать и какой результат нельзя обещать без отдельного теста.

\n

Полезно различать пять уровней:

\n\n

RFC может подтвердить смысл поля протокола, но не совместимость нашего SDK. Commit может подтвердить содержимое файла, но не успешность запуска в другой среде. Отчёт теста может подтвердить один сценарий, но не «работает всегда». Скачок от наблюдения к решению и есть место, где чаще всего появляется лишняя уверенность.

\n
\"Матрица
Сначала закрепляем представление и читаемый артефакт, затем ограничиваем утверждение областью применения. Статус выбирает следующее действие, а не оценивает авторитет сайта.
\n

Что на самом деле показывают дата и ETag

\n

HTTP передаёт не сам ресурс, а его представление — данные и метаданные, выбранные для конкретного запроса. Поэтому один URL может давать разные представления из-за языка, формата, кодировки или других условий согласования. В RFC 9110 поле ETag определено как непрозрачный валидатор выбранного представления. Сервер сам выбирает способ его формирования; клиенту нужно сравнивать значение по правилам HTTP, а не расшифровывать его.

\n

Из ETag: \"8.1\" нельзя заключить, что перед нами релиз 8.1, документ опубликован в августе или другой endpoint поддерживает тот же контракт. Это может быть хеш, внутренний идентификатор или другое значение, различающее представления. Слабый валидатор с префиксом W/ дополнительно имеет иные правила сравнения.

\n

Last-Modified сообщает дату последнего изменения представления в смысле HTTP-валидатора. Она не обязана совпадать с датой выпуска продукта, датой утверждения API или временем, когда команда впервые прочитала документ. Заголовок Vary может указать, какие поля запроса участвуют в выборе представления. Ни один из этих заголовков не заменяет pin релиза, commit или датированный документ.

\n

Практическая запись должна хранить их раздельно:

\n
sourceUrl: https://api.example.test/docs\nsourcePin: vendor-api-2.4.1\nlocator: \"GET /reports, section Conditional requests\"\nobservedAt: 2025-11-15T10:00:00Z\netag: \"8.1\"\nlastModified: Sat, 15 Nov 2025 10:00:00 GMT
\n

Значения в примере учебные. sourcePin отвечает на вопрос «какую версию документа закрепили», а etag — «какой валидатор сообщил сервер для выбранного представления». Сохранять оба полезно, но подменять одно другим нельзя.

\n

Целостность документа и смысл утверждения

\n

Цифровая подпись решает криптографическую задачу: при корректной проверке она даёт сведения о происхождении и целостности подписанных данных, а также поддерживает неотказуемость подписанта в заданной инфраструктуре. Это не означает, что каждый тезис документа подходит нашему продукту или что авторский прогноз стал измеренным результатом.

\n

Например, подписанный файл может описывать старую версию, другой метод или условие, которого нет в нашей системе. Подпись защищает связь с конкретными данными; область применимости и смысл нужно проверять отдельно. У записи исследования поэтому должны быть как минимум две независимые строки: integrity и claimEvidence.

\n

Эту же границу помогает увидеть модель происхождения W3C PROV. В ней документ — сущность, чтение или преобразование — действие, а человек, сервис или программа — участник, связанный с ответственностью. Время, версия и связь между редакциями делают историю проверяемой. PROV описывает происхождение, но не выдаёт автоматический рейтинг качества и не доказывает деловую пригодность решения.

\n

Воспроизводимый пример с отрицательной веткой

\n

Ниже — самостоятельный скрипт без сети, базы данных и настоящей криптографии. Он проверяет только структуру записи и не притворяется проверкой внешнего мира. Сохраните фрагмент в файл claim-check.mjs, затем выполните команды:

\n
node --version\nnode claim-check.mjs
\n
const record = {\n  sourceUrl: 'https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3',\n  sourcePin: 'RFC 9110',\n  locator: 'section 8.8.3',\n  scope: 'HTTP response; selected representation',\n  observation: 'ETag is an opaque validator for differentiating representations',\n  claim: 'ETag is the software release number'\n};\n\nfunction assess(item) {\n  const required = ['sourceUrl', 'sourcePin', 'locator', 'scope', 'observation', 'claim'];\n  const missing = required.filter((field) => !item[field]);\n\n  if (missing.length > 0) {\n    return { status: 'hold', reason: 'missing-field', missing };\n  }\n\n  if (item.claim === 'ETag is the software release number') {\n    return { status: 'hold', reason: 'observation-does-not-support-claim' };\n  }\n\n  return { status: 'ready-for-scoped-use', reason: 'record-is-addressable' };\n}\n\nconsole.log(assess(record));\n// { status: 'hold', reason: 'observation-does-not-support-claim' }
\n

На обычном Node.js скрипт напечатает отказ: запись адресуемая, но наблюдение говорит о валидаторе представления, а утверждение называет его номером релиза. Если заменить claim на «ETag различает представления в данном HTTP-контексте», локальная проверка пройдёт. Это всё ещё не доказывает поведение конкретного сервера: для него нужен отдельный запрос с зафиксированными входами.

\n

Отрицательная ветка важнее красивого статуса ready. Удалите sourcePin, измените метод или расширьте утверждение с одного ресурса на весь API — запись должна перейти в hold либо потребовать нового наблюдения. Список обязательных полей защищает журнал от пустой ссылки, но не превращает заполненную карточку в эксперимент.

\n

Сигнал, граница и следующий шаг

\n
Как использовать сигнал источника, не расширяя его смысл
СигналЧто подтверждаетЧего не подтверждаетСледующая проверка
Номер RFC, релиз или commitК какому закреплённому материалу относится цитатаЧто система внедрила этот контрактСверить локатор и версию клиента/сервера
ETagВалидатор выбранного HTTP-представленияНомер релиза и бизнес-смысл ресурсаПроверить условный запрос и правила сравнения
Last-ModifiedДату изменения представления по правилам HTTPДату выпуска продукта или дату утверждения APIНайти официальный release note или тег
Цифровая подписьЦелостность и происхождение подписанных данных при корректной проверкеИстинность каждого тезиса и совместимость с нашим кодомПроверить scope, ключ, политику и независимый факт
Текущий URLКуда можно перейти сейчасКак выглядел документ в момент решенияЗакрепить версию, commit, PDF или архивный снимок
Результат тестаПоведение в указанных входах и средеПоведение во всех версиях, ролях и нагрузкахПовторить сценарий и перечислить ограничения
\n

Порядок проверки перед решением

\n
  1. Назовите действие. Запишите, что именно хотите изменить: метод, endpoint, зависимость, конфигурацию или текст инструкции.
  2. Ограничьте объект. Укажите версию, среду, права, метод HTTP, формат данных и другие признаки, от которых зависит результат.
  3. Выберите первичный источник. Для протокола используйте нормативный документ, для библиотеки — официальный релиз и документацию владельца, для кода — конкретный commit.
  4. Закрепите представление. Сохраните pin, дату чтения и, если это HTTP-ответ, безопасные для публикации заголовки. Текущий URL оставьте как навигацию.
  5. Запишите локатор. Это раздел RFC, страница PDF, путь к файлу, endpoint или точное поле. Ссылка на главную страницу недостаточна.
  6. Перепишите наблюдение без вывода. Оставьте то, что можно увидеть или повторить. Не заменяйте «сервер прислал ETag» фразой «версия совместима».
  7. Сопоставьте силу утверждения. Сверьте субъект, метод, версию и условие. Слова «всегда», «безопасно» и «для любого клиента» требуют отдельного доказательства.
  8. Проверьте отрицательный путь. Уберите pin, измените scope или подставьте другой режим. Проверка должна остановиться с понятной причиной, а не сохранить прежний совет.
  9. Выберите следующее действие. Это может быть scoped use, новый запрос, тест, запрос к владельцу документа или отказ от решения. Запишите критерий, по которому вернётесь к карточке.
\n

Когда нужно остановиться

\n

Остановитесь, если документ нельзя однозначно закрепить, локатор ведёт в другой раздел, текущая страница изменилась, а исторического представления нет, или подпись относится не к тому файлу. Точно так же остановитесь, если в тексте есть только рекламное обещание, а решение требует измерения производительности, безопасности или совместимости.

\n

Не заменяйте остановку усреднённым баллом доверия. Две ссылки на разные версии не становятся сильнее от сложения. Подписанный документ другой области не подтверждает наш endpoint. Пустое поле «дата» не компенсируется свежим впечатлением от страницы. Полезный результат в таких случаях — список: какого факта не хватает, где его получить и какой вывод пока запрещён.

\n

Ограничения применимости

\n

Метод не делает интернет неизменяемым и не превращает ссылку в лабораторный результат. Он не измеряет задержку, не проверяет нагрузку, не выполняет цифровую подпись и не устанавливает, что конкретный сервис соблюдает документ. Для этого нужны реальные входы, разрешённая среда, измерительный инструмент и отдельный критерий успеха.

\n

RFC описывает семантику HTTP, но не знает, как владелец API строит ETag. NIST описывает свойства цифровой подписи, но не проверяет доверенную цепочку ключей в вашем проекте. W3C PROV задаёт vocabulary для происхождения, но не требует конкретного формата журнала и не решает конфликт двух корректных источников. Эти границы нужно переносить в карточку решения, а не прятать в примечании.

\n

Учебный скрипт намеренно не обращается к сети. Его зелёный результат означает лишь, что локальная запись заполнена и не содержит известного слишком сильного вывода. Перед изменением production-кода дополнительно проверьте конкретную версию, права, данные, обратимость и влияние на пользователей.

\n

Критерий готовности

\n

Запись готова к использованию, когда другой инженер может без устного пояснения открыть закреплённый источник, перейти к локатору, увидеть наблюдение, назвать область применения и воспроизвести следующий шаг. При изменении версии, метода или объекта проверка должна показать, почему старый вывод больше не действует.

\n

Минимальная форма результата выглядит так: «В RFC 9110, разделе 8.8.3, ETag назван непрозрачным валидатором выбранного представления. Для GET ресурса X в версии Y проверяем условный запрос. Это не доказывает номер релиза, совместимость записи или поведение другого endpoint». Такая формулировка уже не обещает лишнего и оставляет команде проверяемое действие.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/078.json b/editorial/agent-rewrites/078.json index f3ec28b..c8bc82c 100644 --- a/editorial/agent-rewrites/078.json +++ b/editorial/agent-rewrites/078.json @@ -3,5 +3,5 @@ "slug": "editorial-2025-11-practice-research-method", "title": "Как превратить ссылку в проверяемое техническое утверждение", "excerpt": "Ссылка сама по себе не доказывает решение. Разбираем журнал утверждений: как зафиксировать версию источника, область применимости, наблюдение, отрицательный путь и следующий проверяемый шаг.", - "contentHtml": "

Ошибка в исследовании источников обычно обнаруживается поздно. В тексте стоит ссылка на документацию, но читатель не знает, какую версию открывал автор, где находится нужный факт и при каком условии он перестаёт работать. В коде такой совет превращается в неверный запрос, несовместимую настройку или лишнюю переделку. Цена ошибки — часы на повторную проверку и решение, которое нельзя уверенно объяснить после обновления документа.

\n

Ссылка — это адрес. Утверждение — это ограниченная фраза, которую можно проверить. Между ними нужен короткий журнал: statement, scope, source pin, locator, observation и status. Такая запись не делает источник истинным. Она показывает, какой факт прочитан, где он находится и какое действие он разрешает.

\n

Что именно нужно зафиксировать

\n

Начинайте с одного предложения. «ETag ускоряет кеширование» слишком широко: здесь не названы протокол, представление, условие запроса и ожидаемый результат. «Для выбранного HTTP-представления сильное сравнение ETag позволяет проверить условие If-None-Match» уже имеет границу. Его можно сопоставить с разделом спецификации и с наблюдаемым запросом.

\n

У утверждения есть шесть полей. statement хранит фразу. scope описывает, где она действует. sourcePin фиксирует версию первичного материала: RFC, tagged release или датированный PDF. locator указывает раздел, таблицу или endpoint. observation записывает минимальный факт без расширения смысла. status управляет следующим шагом: можно передавать запись дальше или нужно остановиться.

\n
\"Лестница
Доказательство усиливается не числом ссылок, а связью между версией, локатором и наблюдаемым фактом.
\n

Механизм: отделить источник от наблюдения

\n

URL без версии ведёт на текущую страницу. Он не доказывает, что документ выглядел так же в момент чтения. Поэтому журнал хранит pin отдельно. Датированный RFC или commit отвечает на вопрос «какой материал открыт». Locator отвечает на вопрос «где искать». Observation отвечает на вопрос «что там написано или возвращено».

\n

Это разделение не формальность. Заголовок ETag — непрозрачный валидатор выбранного представления. Он помогает отличать представления ресурса, но сам по себе не описывает бизнес-смысл данных и не обещает совместимость конкретного SDK. Из факта о протоколе нельзя автоматически вывести факт о продукте.

\n

Полезно также различать целостность и смысл. Подписанный документ подтверждает происхождение или неизменность представления, если проверка подписи прошла. Это не доказывает, что интерпретация автора подходит другому endpoint, региону, праву доступа или версии клиента. Если источник сообщает только «подпись действительна», журнал должен остановить более сильное утверждение.

\n

Минимальный пример

\n

Ниже учебный пример. Он не обращается к сети и не имитирует результат production-сервиса. Функция проверяет только полноту локальной записи: pin, область, locator и наблюдение. Такой код полезен для демонстрации границы метода, а не для оценки качества реального источника.

\n
const claim = {\n  statement: 'If-None-Match compares the selected representation',\n  scope: 'HTTP conditional GET',\n  sourcePin: 'RFC-9110',\n  locator: 'section 8.8.3',\n  observation: 'ETag is an opaque validator for a selected representation',\n};\n\nfunction assess(record) {\n  const required = ['statement', 'scope', 'sourcePin', 'locator', 'observation'];\n  const missing = required.filter((field) => !record[field]);\n\n  if (missing.length) {\n    return { status: 'hold', nextAction: 'repair-record', missing };\n  }\n\n  return { status: 'ready-for-scoped-handoff', nextAction: 'use-with-scope' };\n}\n\nconsole.log(assess(claim));\n// { status: 'ready-for-scoped-handoff', nextAction: 'use-with-scope' }
\n

Статус ready-for-scoped-handoff означает только одно: запись адресуемая и содержит наблюдение. Он не означает, что измерена производительность, проверена совместимость или доказан эффект для продукта. Чтобы не потерять эту границу, следующий шаг хранится рядом со статусом.

\n

Отрицательная ветка обязательна. Если удалить sourcePin, функция вернёт hold. Добавление ещё одной цитаты вокруг текущей страницы не исправит отсутствие версии. Нужно найти неизменяемый первичный материал, открыть locator заново и записать новый observation. Если источник содержит маркетинговое обещание, его можно сохранить как наблюдение текста, но нельзя выдавать за измеренный результат.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Ссылка открывается, но факт не находитсяНет locator или он относится к другой версииОткрыть pinned material и найти раздел зановоДобавить точный locator; при несовпадении остановить вывод
Совет звучит уверенно, но не имеет условияТема заменяет проверяемое утверждениеНазвать объект, вход, результат и границуСузить statement до одной проверяемой фразы
Подписанный файл принимают за доказательство поведенияЦелостность смешали с семантической истинностьюСверить observation с заявленным выводомОставить только вывод об авторстве или найти независимый факт
Две ссылки дают «среднюю уверенность»Сравнивают разные классы источниковПроверить одинаковые scope, термин и объектНе агрегировать записи до устранения несовместимости
После обновления API совет ломаетсяЗафиксирован только корневой URLСравнить дату, release или commit с датой решенияДобавить source pin и повторить проверку
\n

Порядок проверки

\n
  1. Сформулируйте одну фразу. Уберите слова «лучше», «быстрее» и «надёжнее», если рядом нет объекта, условия и способа измерения.
  2. Назовите область. Укажите протокол, версию API, тип документа, права, среду или другой фактор, который ограничивает вывод.
  3. Зафиксируйте первичный материал. Используйте RFC, официальную спецификацию, документацию владельца API или tagged release. Сохраните версию, дату или commit.
  4. Найдите локатор. Запишите раздел, страницу, таблицу, endpoint или короткий уникальный фрагмент. Корневого URL недостаточно.
  5. Перепишите наблюдение. Сохраните только тот факт, который виден в источнике. Не добавляйте к нему обещание о продукте, которого там нет.
  6. Проверьте отрицательный путь. Удалите pin, измените scope или подставьте маркетинговую фразу. Запись должна перейти в hold, а не остаться «почти подтверждённой».
  7. Назначьте действие. Укажите, что разрешено сделать дальше: использовать с ограничением, найти версию, получить измерение или остановить решение.
\n

Ограничения метода

\n

Журнал не заменяет эксперимент. Он не измеряет задержку, не проверяет нагрузку и не устанавливает, что API одинаково ведёт себя во всех средах. Он только связывает фразу с источником и наблюдением. Для утверждения о производительности нужен воспроизводимый тест с входами, условиями и метрикой. Для утверждения о безопасности нужны модель угроз, область действия и отдельная проверка.

\n

Метод также не решает спор между двумя корректными первичными источниками. Если версии или области различаются, журнал должен показать это различие. Решение появится после явного критерия: совместимость, дата поддержки, стоимость миграции или другой измеримый приоритет. Нельзя скрывать отсутствие критерия под статусом «достаточно надёжно».

\n

Учебный код выше проверяет структуру записи, а не внешний мир. В настоящем исследовании нужно сохранить ссылку на доступный первичный документ, дату чтения и способ воспроизвести observation. Если источник исчез, изменился или не отвечает на нужный вопрос, корректный результат — hold.

\n

Проверяемый критерий готовности

\n

Запись готова к использованию, когда другой инженер без устного пояснения может открыть зафиксированный источник, перейти к locator, увидеть observation, назвать scope и понять следующий шаг. Проверка должна пройти и для отрицательной ветки: при отсутствии версии, несовпадении области или подмене факта обещанием статус блокирует передачу.

\n

Это небольшой критерий, но он защищает решение от самой дорогой подмены: ссылка начинает выглядеть как доказательство только потому, что её никто не перепроверил.

\n

Проверяемые источники

" + "contentHtml": "

Ошибка в исследовании источников обычно обнаруживается поздно. В тексте стоит ссылка на документацию, но читатель не знает, какую версию открывал автор, где находится нужный факт и при каком условии он перестаёт работать. В коде такой совет превращается в неверный запрос, несовместимую настройку или лишнюю переделку. Цена ошибки — часы на повторную проверку и решение, которое нельзя уверенно объяснить после обновления документа.

\n

Ссылка — это адрес. Утверждение — ограниченная фраза, которую можно проверить. Между ними нужен короткий журнал: statement, scope, source pin, locator, observation и status. Такая запись не делает источник истинным. Она показывает, какой факт прочитан, где он находится и какое действие он разрешает.

\n

Начните с наблюдаемой проблемы

\n

Хорошее исследование начинается не с коллекции ссылок, а с вопроса, на который нужно принять решение. Например: «Можно ли включить условный запрос к нашему API, не меняя смысл ответа?» Это лучше, чем «как работает кеширование»: в первом вопросе названы действие, объект и риск.

\n

Зафиксируйте симптом до чтения документа. Клиент повторно скачивает один и тот же JSON, время ответа растёт, а команда предлагает добавить заголовок из статьи в интернете. Пока неизвестны версия API, поддержка прокси и семантика ответа, это гипотеза, а не план исправления. Следующий шаг — найти первичный контракт и проверить его на нужном представлении.

\n

Разделите источник, представление и вывод

\n

URL без версии ведёт на текущую страницу. Он не доказывает, что документ выглядел так же в момент чтения. Поэтому журнал хранит pin отдельно: датированный RFC, commit, тег релиза или опубликованный PDF отвечает на вопрос «какой материал открыт». Locator отвечает на вопрос «где искать». Observation отвечает на вопрос «что там написано или возвращено».

\n

Нужно разделять как минимум три свойства. Происхождение показывает, кто опубликовал документ и к какому объекту он относится. Целостность показывает, не изменилось ли сохранённое представление после захвата. Смысл показывает, поддерживает ли найденный фрагмент именно наше решение. Подписанный файл или совпавший хеш усиливает первые два вывода, но не превращает интерпретацию в доказанный факт.

\n
\"Лестница
Ссылка становится основанием решения только после фиксации версии, локатора и наблюдаемого факта.
\n

В HTTP эта граница хорошо видна на примере ETag. RFC 9110 описывает его как валидатор выбранного представления ресурса. Значение может быть непрозрачным: из ETag: \"a81c\" нельзя вывести релиз 8.1, дату публикации или совместимость любого SDK. Правила сравнения зависят от контекста условного запроса. Поэтому честное утверждение звучит так: «Для выбранного HTTP-представления ETag можно использовать в условном запросе по правилам RFC». Вывод «ETag ускоряет наше приложение на 20%» требует уже измерения.

\n

Соберите карточку утверждения

\n

Перед тем как писать совет, заполните одну карточку. Для производственного решения в ней стоит хранить и исходный запрос: язык, заголовки, права, параметры и среду. Иначе две команды могут открыть один URL и получить разные представления.

\n
const claim = {\n  statement: 'ETag can participate in a conditional HTTP request',\n  scope: 'HTTP; selected representation; target service contract',\n  source: {\n    url: 'https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3',\n    pin: 'RFC 9110, published June 2022',\n    locator: 'section 8.8.3'\n  },\n  observation: 'ETag is a validator for a selected representation',\n  evidence: {\n    capturedAt: '2025-11-07T10:00:00Z',\n    sha256: 'record-the-local-file-hash'\n  },\n  nextCheck: 'send a conditional request to the target service'\n};\n\nfunction assess(record) {\n  const required = [\n    'statement', 'scope', 'source', 'observation', 'evidence', 'nextCheck'\n  ];\n  const missing = required.filter((key) => !record[key]);\n  const sourceReady = record.source?.url &&\n    record.source.pin && record.source.locator;\n  const evidenceReady = record.evidence?.capturedAt &&\n    record.evidence.sha256;\n\n  if (missing.length || !sourceReady || !evidenceReady) {\n    return { status: 'hold', missing };\n  }\n  return { status: 'ready-for-scoped-check' };\n}\n\nconsole.log(assess(claim));
\n

Функция проверяет структуру карточки, а не истинность утверждения. Статус ready-for-scoped-check означает только, что другой инженер может найти источник и понять следующий шаг. Он не означает, что измерена производительность, проверена авторизация или доказан эффект в конкретном продукте.

\n

Отрицательная ветка обязательна. Если удалить source.pin, изменить scope или оставить пустым хеш, карточка должна перейти в hold. Добавление ещё одной ссылки вокруг текущей страницы не исправит отсутствие версии. Нужно найти неизменяемый первичный материал, открыть locator заново и записать новое наблюдение.

\n

Зафиксируйте конкретное представление

\n

Сначала сохраните представление, которое собираетесь цитировать. Команда ниже фиксирует ответ и его хеш, чтобы позднее обнаружить изменение файла. Она не доказывает, что страница навсегда неизменна, и не заменяет чтение нужного раздела. Каталог создаётся локально: путь следует выбрать по правилам проекта.

\n
set -eu\nmkdir -p evidence/rfc-9110\ncurl --fail --silent --show-error --location \\\n  --dump-header evidence/rfc-9110/headers.txt \\\n  --output evidence/rfc-9110/body.html \\\n  'https://www.rfc-editor.org/rfc/rfc9110.html'\nshasum -a 256 evidence/rfc-9110/body.html \\\n  | tee evidence/rfc-9110/body.sha256\ngrep -niE '^(HTTP/|etag:|last-modified:|content-type:)' \\\n  evidence/rfc-9110/headers.txt || true
\n

curl --fail останавливает команду на HTTP-ошибке, а --location следует редиректам. shasum -a 256 есть в macOS; в Linux его можно заменить на sha256sum. Хеш относится к сохранённому телу, а не к смыслу абзаца и не к версии API. В карточке дополнительно запишите дату, фактический URL после редиректа, заголовки запроса, версию клиента и locator.

\n

Проверка повтора должна сравнивать тот же артефакт, а не только снова открывать URL:

\n
shasum -a 256 --check evidence/rfc-9110/body.sha256
\n

Если команда сообщает о несовпадении, сначала выясните, изменился ли файл, кодировка или путь. Не обновляйте вывод автоматически: повторно откройте locator и решите, осталось ли утверждение применимым. Если документ доступен только после авторизации, публичный читатель не сможет воспроизвести представление; это нужно назвать ограничением, а не замаскировать пересказом.

\n

Симптом → причина → проверка → действие

\n
Диагностика слабой ссылки
СимптомПричинаПроверкаДействие
Ссылка открывается, но факт не находитсяНет локатора или он относится к другой версииОткрыть зафиксированный файл и найти раздел зановоДобавить точный locator; при несовпадении поставить hold
Совет обещает «быстрее» или «надёжнее»Нет объекта, условия и метрикиНазвать вход, результат и способ измеренияСузить фразу или провести отдельный тест
Совпадение хеша принимают за доказательство поведенияЦелостность смешали со смысломСопоставить observation и claim буквальноОставить вывод о файле; продуктовый вывод проверить отдельно
Документ доступен только после входаЧитатель не может воспроизвести представлениеПроверить права, среду и разрешённый способ публикацииДать общедоступный первичный источник или описать ограничение
После обновления API совет ломаетсяСохранён только корневой URLСравнить релиз, commit, дату и контракт endpointОбновить карточку и повторить проверку с новой областью
\n

Порядок проверки от вопроса до решения

\n
  1. Сформулируйте вопрос. Не «как работает кеш», а «при каком условии этот клиент может отправить If-None-Match для выбранного представления».
  2. Запишите ограниченное утверждение. Уберите «всегда», «любой» и «безопасно», если нет отдельного доказательства для такого охвата.
  3. Назовите область. Укажите протокол, версию API, тип документа, права, среду и входные параметры.
  4. Выберите первичный источник. Для протокола используйте стандарт; для API — документацию владельца и контракт нужной версии; для кода — конкретный commit или tagged release.
  5. Закрепите представление. Сохраните URL, дату, редирект, хеш, заголовки запроса и неизменяемый pin, если он существует.
  6. Добавьте locator и observation. Другой инженер должен увидеть тот же фрагмент и отличить дословный факт от вашей интерпретации.
  7. Проверьте отрицательную ветку. Удалите pin, измените scope или подставьте маркетинговую фразу. Карточка должна перейти в hold, а не остаться «почти подтверждённой».
  8. Назначьте проверяемое действие. Это может быть условный запрос, тест совместимости, нагрузочное измерение или отказ от внедрения. Результат действия запишите отдельным наблюдением.
\n

Переход от факта к рекомендации

\n

Держите несколько уровней, чтобы не выдавать гипотезу за факт. Наблюдение — то, что видно в документе или ответе. Ограниченное утверждение — интерпретация с явно указанной областью. Рекомендация — действие для конкретной системы после проверки входов и рисков. Результат — наблюдаемое изменение в этой системе с методикой измерения.

\n

Например, из RFC можно получить наблюдение о валидаторе выбранного представления и утверждение о семантике условного HTTP-запроса. Нельзя из него сразу получить результат «наша CDN экономит 20% трафика». Для такого вывода нужны URL, размеры представлений, политика кеша, доля условных запросов и повторяемое измерение до и после изменения.

\n

Цифровая подпись или хеш усиливают цепочку происхождения, но не расширяют scope. Если подписанный документ описывает режим read-only для версии 2, подпись не подтверждает запись в версии 3. Если официальный источник отвечает на другой вопрос, чем тот, который стоит перед командой, его авторитетность не устраняет несовпадение.

\n

Границы метода

\n

Журнал источников не заменяет эксперимент, ревью угроз или контрактную проверку. Он не измеряет задержку, не проверяет нагрузку и не устанавливает, что API одинаково ведёт себя во всех регионах и ролях. Для производительности нужны входы, условия, число повторов и метрика. Для безопасности нужны модель угроз, права, негативные сценарии и проверка фактической реализации.

\n

Хеш не защищает от неверного первоисточника, устаревшей спецификации или ошибки копирования. ETag не является универсальным идентификатором релиза. Дата публикации не говорит, что ваш клиент поддерживает описанный режим. Даже официальный документ может быть неполным для конкретной интеграции.

\n

Метод также не разрешает конфликт двух корректных источников автоматически. Если версии или области различаются, карточки должны показать различие. Решение появляется после явного критерия: совместимость, дата поддержки, стоимость миграции, риск или измеримый эффект. Нельзя скрывать отсутствие критерия под словом «надёжно».

\n

Проверяемый критерий готовности

\n

Карточка готова к передаче, когда инженер без устного пояснения может открыть зафиксированный материал, перейти к locator, увидеть observation, назвать scope и выполнить next check. Проверка должна пройти и для отрицательного пути: при отсутствии версии, несовпадении области, изменившемся хеше или подмене факта обещанием статус блокирует рекомендацию.

\n

Для практического ревью достаточно задать пять вопросов: какой именно файл или ответ мы проверили; где расположен факт; что подтверждает хеш; какое утверждение разрешено в этой области; какое наблюдение подтвердит действие в нашей системе. Если на последний вопрос отвечают ссылкой, работа ещё не закончена: ссылка даёт исходный материал, а результат должен появиться в отдельном воспроизводимом тесте.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/079.json b/editorial/agent-rewrites/079.json index 3c0167d..fd52ed8 100644 --- a/editorial/agent-rewrites/079.json +++ b/editorial/agent-rewrites/079.json @@ -1,7 +1,7 @@ { "index": 79, "slug": "editorial-2025-10-field-teaching-engineering", - "title": "Promise.all не отменяет работу: как объяснить границу на рабочем примере", - "excerpt": "После первой ошибки Promise.all возвращает rejected promise, но уже начатая работа входов не исчезает сама. Разбираем модель, контрпример и проверку, которые не дают перенести рецепт за пределы его условий.", - "contentHtml": "

Симптом появляется в момент первой ошибки. Код ждёт несколько операций через await Promise.all(tasks), одна операция отклоняется, обработчик сразу переходит в catch, а автор считает остальные операции остановленными. Позже выясняется, что один запрос всё ещё пишет данные, таймер всё ещё выполняется, а зависимая логика уже очистила состояние. Цена ошибки — повторные записи, лишние запросы и расследование, в котором приходится восстанавливать границу ответственности по логам.

\n

Тезис простой: Promise.all объединяет наблюдаемые исходы promises, но не является протоколом отмены работы. Он сообщает результат aggregate promise. Он не получает автоматически право остановить операцию, которая создала входной promise. Чтобы объяснить такой код без ложной гарантии, нужно разделить три объекта: входной promise, aggregate promise и внешнюю работу.

\n

Сначала назовите задачу

\n

Рецепт становится опасным, когда его показывают раньше задачи. Возьмём учебный сценарий: нужно получить профиль и настройки, а затем построить экран. Если любой результат недоступен, экран строить нельзя. Здесь Promise.all подходит как условие для зависимого шага: зависимый код запускается только после успешного исхода двух входов.

\n

Но это условие не отвечает на другой вопрос: что делать с операцией, которая уже началась и ещё не завершилась? Объединение результатов и остановка работы относятся к разным контрактам. Первый задаёт JavaScript. Второй должен задавать приложение, библиотека или владелец ресурса.

\n
\"Цикл
Правильное объяснение ведёт от задачи к модели, затем показывает оставшийся вход после reject и только после этого говорит о следующем действии. Схема учебная: она не показывает реальный сетевой trace и не доказывает поведение конкретного сервиса.
\n

Механизм: два исхода вместо одного

\n

Пусть в Promise.all переданы profilePromise и settingsPromise. JavaScript создаёт новый aggregate promise. Он выполнится успешно, если все входы выполнятся. Значения попадут в массив в порядке входного iterable, а не в порядке завершения. Если один вход отклонится, aggregate promise отклонится с первой причиной отказа.

\n

Это не означает, что второй вход получил команду отмены. Второй promise может уже завершиться, продолжить ожидание или скрывать за собой работу, которую можно прервать только отдельным API. Вызов catch наблюдает отказ aggregate. Сам по себе он не меняет жизненный цикл запроса, чтения файла, вычисления или записи.

\n

Отсюда следует отрицательный путь. Если профиль отклонился, нельзя выводить «настройки отменены». Можно вывести только «общий результат недоступен с такой причиной». Дальше код обязан использовать собственный контракт: сигнал отмены, закрытие ресурса, остановку очереди, компенсацию или безопасное ожидание остатка. Если такого контракта нет, честный ответ — отмена не определена.

\n

Минимальный контрпример

\n

Ниже намеренно узкий пример. Он не обращается к сети, диску, часам или данным пользователей. Один вход отклоняется сразу. Второй вход удерживается до явного вызова finishRemaining. Это позволяет увидеть порядок событий и не приписывать JavaScript поведение, которого в коде нет.

\n
let finishRemaining;\nconst remaining = new Promise((resolve) => {\n  finishRemaining = () => resolve('settings');\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('profile failed')),\n  remaining,\n]).catch(() => 'aggregate handled');\n\nawait combined;\nconsole.log('aggregate rejected');\nfinishRemaining();\nconsole.log(await remaining);\n// aggregate rejected\n// settings
\n

После первой строки вывода aggregate уже обработал отказ. Но второй promise ещё существует. Функция finishRemaining завершает его позже. Пример доказывает только это: rejected aggregate не равен отмене каждого входа. Он не доказывает, как поведёт себя HTTP-клиент, база данных или очередь сообщений. Для каждого такого ресурса нужна отдельная документация и отдельный тест.

\n

Контрпример полезнее общей фразы «promises выполняются параллельно». Это слово слишком широкое. В JavaScript promise представляет состояние будущего результата; он не является универсальным дескриптором процесса, который можно остановить одним методом. Операции могут стартовать до создания aggregate, а их остановка может быть невозможна или требовать согласия внешнего владельца.

\n

Три уровня, которые нельзя смешивать

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но запрос продолжилсяAggregate наблюдает отказ, а клиент запроса не получил сигнал отменыПосмотреть API запроса и его обработчик сигналаДобавить явный cancel-контракт или признать работу неотменяемой
Результаты приходят в неожиданном порядкеПорядок завершения перепутан с порядком iterableСравнить индексы входов и trace завершенияЧитать массив по исходным индексам, а не по времени
После одной ошибки обработчик пишет частичный результатЗависимый шаг запускается не только после fulfilled aggregateПроверить место вызова и ветки после catchРазделить partial result и готовый aggregate
Заменили all на allSettled, но проблема осталасьНужен был протокол остановки, а изменили только форму отчётаНазвать требование: все исходы или остановка работыВыбрать combinator для отчёта и отдельно спроектировать отмену
Текст обещает «остановить всё»Рецепт подменил причинную модельПопросить показать объект, который посылает cancelСузить утверждение до aggregate и добавить отрицательный путь
\n

Как объяснить код без лишней теории

\n

Начните с одной проверяемой фразы: «Экран строится только после двух успешных результатов». Затем назовите aggregate promise и покажите, где читается его результат. После этого добавьте отказ одного входа. Читатель должен увидеть, что зависимый шаг не запускается. Только теперь задайте вопрос о втором входе.

\n

Ответ должен быть конкретным. Если второй вход уже создан, у него есть собственное состояние. Promise.all не предоставляет в этом вызове метода cancel. Если вход связан с fetch, автор может передать AbortSignal и вызвать AbortController.abort(). Но это уже договор Fetch и конкретного кода приложения, а не свойство Promise.all. Даже сигнал не превращает любую серверную операцию в гарантированно отменённую: сервер мог принять запрос, а клиент мог лишь прекратить ожидание ответа.

\n

Такой ответ не должен звучать как универсальный рецепт отмены. Для учебного фрагмента достаточно показать место ответственности. В production нужно проверить, что делает клиент после abort, что происходит на сервере, как закрывается ресурс и допустима ли повторная попытка. Если эти условия не описаны, статья должна остановиться на границе знания.

\n

Когда нужен другой combinator

\n

Promise.all выбирают, когда нужен общий успех всех входов и ранний отказ aggregate при первой ошибке. Promise.allSettled выбирают, когда нужно дождаться и сохранить статус каждого входа. Это разные требования. Переключение на allSettled не отменяет операции и не исправляет частичную запись.

\n

Например, экран может показывать независимые виджеты. Тогда полезно получить массив состояний и отрисовать ошибку только у одного виджета. Но платёжный сценарий, в котором нельзя продолжать без обязательного ответа, требует другой проверки: dependent action не должна начаться после rejected aggregate. Если же один вызов уже создал побочный эффект, combinator не решает вопрос компенсации. Его должен решить доменный контракт.

\n

Не стоит заменять Promise.all на последовательный await только ради иллюзии контроля. Последовательный запуск уменьшает число одновременно начатых операций, но не отменяет первую операцию при ошибке второй. Он также меняет задержку и нагрузку. Сначала зафиксируйте требование, затем выбирайте форму ожидания.

\n

Проверяемый порядок действий

\n
  1. Запишите исходную задачу одним предложением: какие результаты нужны и какой шаг зависит от них.
  2. Назовите каждый входной promise и работу, которая стоит за ним. Не называйте promise самой работой.
  3. Покажите fulfilled-путь: все входы завершились, aggregate вернул значения в порядке iterable, зависимый шаг получил полный набор.
  4. Покажите отрицательный путь: один вход отклонился, aggregate отклонился, зависимый шаг не стартовал.
  5. Проверьте оставшийся вход отдельным наблюдением. Ответьте, может ли он завершиться после reject и кто имеет право его остановить.
  6. Если нужна отмена, найдите реальный контракт ресурса: сигнал, close, cancel, rollback или другой механизм подтверждения.
  7. Проверьте частичные результаты и побочные эффекты. Отдельно решите, допустимы ли повтор, компенсация и повторный запуск.
  8. Сформулируйте готовность одной проверяемой фразой и приложите тест для положительной и отрицательной ветки.
\n

Ограничения учебного примера

\n

Пример выше фиксирует порядок наблюдений в памяти. Он не моделирует latency, сетевые разрывы, retry, таймауты, серверную обработку, блокировки базы, очередь или пользовательскую сессию. Нельзя переносить его вывод как готовую архитектуру. Его задача уже: показать, почему aggregate rejection не доказывает отмену входа.

\n

Есть и другой отрицательный путь: остановка может быть технически доступна, но логически запрещена. Например, сервер уже принял команду, и отмена клиентского ожидания не должна приводить к повторной команде без idempotency key. В таком случае «прервать ожидание» и «отменить побочный эффект» — два разных решения. Текст, который объединяет их одним словом «cancel», скрывает риск.

\n

Не заявляйте результат обучения или production-эффект по одному примеру. Можно проверить, что читатель назвал aggregate, вход и отдельный cancel-контракт. Нельзя из этого вывести, что он безопасно спроектирует любой асинхронный pipeline. Для такого вывода нужна другая проверка, привязанная к конкретной системе.

\n

Критерий готовности

\n

Объяснение готово, если независимый читатель может без подсказки ответить на четыре вопроса: какой результат объединяет Promise.all; что происходит при первом reject; может ли оставшийся вход закончиться позже; кто именно останавливает внешнюю работу. Код должен проходить тесты для fulfilled-пути и для отказа, а текст — не обещать отмену там, где в API нет такого контракта.

\n

Практическая финальная проверка короткая. Уберите названия методов и попросите восстановить модель по событиям. Затем верните код и сравните каждое утверждение с наблюдаемым результатом. Если для фразы «всё остановилось» нельзя назвать объект, который отправил сигнал остановки, замените её на точное утверждение об aggregate. Такая редактура сохраняет полезный рецепт и не переносит его за пределы условий задачи.

\n

Проверяемые источники

" + "title": "Promise.all не отменяет работу: как не потерять второй запрос", + "excerpt": "Promise.all объединяет результаты, но не останавливает уже начатые операции. На коротком примере разберём first rejection, порядок значений, allSettled и явную отмену через AbortController.", + "contentHtml": "

Симптом появляется в момент первой ошибки. Код ждёт несколько операций через await Promise.all(tasks), одна операция отклоняется, обработчик сразу переходит в catch, а автор считает остальные операции остановленными. Позже выясняется, что один запрос всё ещё пишет данные, таймер всё ещё выполняется, а зависимая логика уже очистила состояние. Цена ошибки — повторные записи, лишние запросы и расследование, в котором приходится восстанавливать границу ответственности по логам.

\n

Тезис простой: Promise.all объединяет наблюдаемые исходы promises, но не является протоколом отмены работы. Он сообщает результат aggregate promise. Он не получает автоматически право остановить операцию, которая создала входной promise. Чтобы объяснить такой код без ложной гарантии, нужно разделить три объекта: входной promise, aggregate promise и внешнюю работу.

\n

Сначала назовите задачу

\n

Рецепт становится опасным, когда его показывают раньше задачи. Возьмём учебный сценарий: нужно получить профиль и настройки, а затем построить экран. Если любой результат недоступен, экран строить нельзя. Здесь Promise.all подходит как условие для зависимого шага: зависимый код запускается только после успешного исхода двух входов.

\n

Но это условие не отвечает на другой вопрос: что делать с операцией, которая уже началась и ещё не завершилась? Объединение результатов и остановка работы относятся к разным контрактам. Первый задаёт JavaScript. Второй должен задавать приложение, библиотека или владелец ресурса.

\n
\"Схема
Для расследования разделяйте события aggregate и каждого входа: запись об ошибке общего ожидания ещё не показывает, что внешняя операция остановилась. Рисунок помогает найти источник и порядок callback, но не заменяет проверку API отмены.
\n

Механизм: два исхода вместо одного

\n

Пусть в Promise.all переданы profilePromise и settingsPromise. JavaScript создаёт новый aggregate promise. Он выполнится успешно, если все входы выполнятся. Значения попадут в массив в порядке входного iterable, а не в порядке завершения. Если один вход отклонится, aggregate promise отклонится с первой причиной отказа.

\n

Это не означает, что второй вход получил команду отмены. Второй promise может уже завершиться, продолжить ожидание или скрывать за собой работу, которую можно прервать только отдельным API. Вызов catch наблюдает отказ aggregate. Сам по себе он не меняет жизненный цикл запроса, чтения файла, вычисления или записи.

\n

Отсюда следует отрицательный путь. Если профиль отклонился, нельзя выводить «настройки отменены». Можно вывести только «общий результат недоступен с такой причиной». Дальше код обязан использовать собственный контракт: сигнал отмены, закрытие ресурса, остановку очереди, компенсацию или безопасное ожидание остатка. Если такого контракта нет, честный ответ — отмена не определена.

\n

Минимальный контрпример

\n

Ниже намеренно узкий пример. Он не обращается к сети, диску, часам или данным пользователей. Один вход отклоняется сразу. Второй вход удерживается до явного вызова finishRemaining. Это позволяет увидеть порядок событий и не приписывать JavaScript поведение, которого в коде нет.

\n
let finishRemaining;\nconst remaining = new Promise((resolve) => {\n  finishRemaining = () => resolve('settings');\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('profile failed')),\n  remaining,\n]).catch(() => 'aggregate handled');\n\nawait combined;\nconsole.log('aggregate rejected');\nfinishRemaining();\nconsole.log(await remaining);\n// aggregate rejected\n// settings
\n

После первой строки вывода aggregate уже обработал отказ. Но второй promise ещё существует. Функция finishRemaining завершает его позже. Пример доказывает только это: rejected aggregate не равен отмене каждого входа. Он не доказывает, как поведёт себя HTTP-клиент, база данных или очередь сообщений. Для каждого такого ресурса нужна отдельная документация и отдельный тест.

\n

Контрпример полезнее общей фразы «promises выполняются параллельно». Это слово слишком широкое. В JavaScript promise представляет состояние будущего результата; он не является универсальным дескриптором процесса, который можно остановить одним методом. Операции могут стартовать до создания aggregate, а их остановка может быть невозможна или требовать согласия внешнего владельца.

\n

Три уровня, которые нельзя смешивать

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но запрос продолжилсяAggregate наблюдает отказ, а клиент запроса не получил сигнал отменыПосмотреть API запроса и его обработчик сигналаДобавить явный cancel-контракт или признать работу неотменяемой
Результаты приходят в неожиданном порядкеПорядок завершения перепутан с порядком iterableСравнить индексы входов и trace завершенияЧитать массив по исходным индексам, а не по времени
После одной ошибки обработчик пишет частичный результатЗависимый шаг запускается не только после fulfilled aggregateПроверить место вызова и ветки после catchРазделить partial result и готовый aggregate
Заменили all на allSettled, но проблема осталасьНужен был протокол остановки, а изменили только форму отчётаНазвать требование: все исходы или остановка работыВыбрать combinator для отчёта и отдельно спроектировать отмену
Текст обещает «остановить всё»Рецепт подменил причинную модельПопросить показать объект, который посылает cancelСузить утверждение до aggregate и добавить отрицательный путь
\n

Как объяснить код без лишней теории

\n

Начните с одной проверяемой фразы: «Экран строится только после двух успешных результатов». Затем назовите aggregate promise и покажите, где читается его результат. После этого добавьте отказ одного входа. Читатель должен увидеть, что зависимый шаг не запускается. Только теперь задайте вопрос о втором входе.

\n

Ответ должен быть конкретным. Если второй вход уже создан, у него есть собственное состояние. Promise.all не предоставляет в этом вызове метода cancel. Если вход связан с fetch, автор может передать AbortSignal и вызвать AbortController.abort(). Но это уже договор Fetch и конкретного кода приложения, а не свойство Promise.all. Даже сигнал не превращает любую серверную операцию в гарантированно отменённую: сервер мог принять запрос, а клиент мог лишь прекратить ожидание ответа.

\n

Такой ответ не должен звучать как универсальный рецепт отмены. Для учебного фрагмента достаточно показать место ответственности. В production нужно проверить, что делает клиент после abort, что происходит на сервере, как закрывается ресурс и допустима ли повторная попытка. Если эти условия не описаны, статья должна остановиться на границе знания.

\n

Проверяемый пример с AbortController

\n

Если входами являются fetch-запросы, общий сигнал делает отмену явной. Сохраните следующий фрагмент как load-pages.mjs и запустите на Node.js 18 или новее командой node load-pages.mjs. URL-ы должны быть доступны вашему тестовому серверу: один endpoint верните с ошибкой, другой задержите.

\n
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) => {\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}
\n

Этот код отменяет только pending fetch, которым передан тот же сигнал. Он не откатывает данные, которые сервер уже принял, и не влияет на операцию, игнорирующую signal. Поэтому тест должен проверять две вещи: второй клиентский запрос получил abort, а серверная команда не требует повторения без идемпотентного ключа.

\n

Для полностью детерминированной проверки границы сети не нужно симулировать production-сервис. Поднимите тестовый endpoint с управляемой задержкой, добавьте в лог request id и сравните время abort() с серверным trace. Если сервер успел выполнить побочный эффект, результатом проверки будет не «всё отменено», а зафиксированное правило компенсации или сверки.

\n

Когда нужен другой combinator

\n

Promise.all выбирают, когда нужен общий успех всех входов и ранний отказ aggregate при первой ошибке. Promise.allSettled выбирают, когда нужно дождаться и сохранить статус каждого входа. Это разные требования. Переключение на allSettled не отменяет операции и не исправляет частичную запись.

\n

Например, экран может показывать независимые виджеты. Тогда полезно получить массив состояний и отрисовать ошибку только у одного виджета. Но платёжный сценарий, в котором нельзя продолжать без обязательного ответа, требует другой проверки: dependent action не должна начаться после rejected aggregate. Если же один вызов уже создал побочный эффект, combinator не решает вопрос компенсации. Его должен решить доменный контракт.

\n

Не стоит заменять Promise.all на последовательный await только ради иллюзии контроля. Последовательный запуск уменьшает число одновременно начатых операций, но не отменяет первую операцию при ошибке второй. Он также меняет задержку и нагрузку. Сначала зафиксируйте требование, затем выбирайте форму ожидания.

\n

Проверяемый порядок действий

\n
  1. Запишите исходную задачу одним предложением: какие результаты нужны и какой шаг зависит от них.
  2. Назовите каждый входной promise и работу, которая стоит за ним. Не называйте promise самой работой.
  3. Покажите fulfilled-путь: все входы завершились, aggregate вернул значения в порядке iterable, зависимый шаг получил полный набор.
  4. Покажите отрицательный путь: один вход отклонился, aggregate отклонился, зависимый шаг не стартовал.
  5. Проверьте оставшийся вход отдельным наблюдением. Ответьте, может ли он завершиться после reject и кто имеет право его остановить.
  6. Если нужна отмена, найдите реальный контракт ресурса: сигнал, close, cancel, rollback или другой механизм подтверждения.
  7. Проверьте частичные результаты и побочные эффекты. Отдельно решите, допустимы ли повтор, компенсация и повторный запуск.
  8. Сформулируйте готовность одной проверяемой фразой и приложите тест для положительной и отрицательной ветки.
\n

Ограничения учебного примера

\n

Пример выше фиксирует порядок наблюдений в памяти. Он не моделирует latency, сетевые разрывы, retry, таймауты, серверную обработку, блокировки базы, очередь или пользовательскую сессию. Нельзя переносить его вывод как готовую архитектуру. Его задача уже: показать, почему aggregate rejection не доказывает отмену входа.

\n

Есть и другой отрицательный путь: остановка может быть технически доступна, но логически запрещена. Например, сервер уже принял команду, и отмена клиентского ожидания не должна приводить к повторной команде без idempotency key. В таком случае «прервать ожидание» и «отменить побочный эффект» — два разных решения. Текст, который объединяет их одним словом «cancel», скрывает риск.

\n

Не заявляйте production-эффект по одному примеру. Проверка должна установить, что aggregate, входной promise и cancel-контракт связаны наблюдаемыми событиями. Она не доказывает безопасность любого асинхронного pipeline; для этого нужен отдельный тест конкретной системы.

\n

Критерий готовности

\n

Объяснение готово, если независимый читатель может без подсказки ответить на четыре вопроса: какой результат объединяет Promise.all; что происходит при первом reject; может ли оставшийся вход закончиться позже; кто именно останавливает внешнюю работу. Код должен проходить тесты для fulfilled-пути и для отказа, а текст — не обещать отмену там, где в API нет такого контракта.

\n

Практическая финальная проверка короткая. Уберите названия методов и попросите восстановить модель по событиям. Затем верните код и сравните каждое утверждение с наблюдаемым результатом. Если для фразы «всё остановилось» нельзя назвать объект, который отправил сигнал остановки, замените её на точное утверждение об aggregate. Такой порядок сохраняет полезный рецепт и не переносит его за пределы условий задачи.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/080.json b/editorial/agent-rewrites/080.json index 8dfed48..1944bac 100644 --- a/editorial/agent-rewrites/080.json +++ b/editorial/agent-rewrites/080.json @@ -1,7 +1,7 @@ { "index": 80, "slug": "editorial-2025-10-mechanism-teaching-engineering", - "title": "Promise.all: почему первая ошибка не останавливает остальные операции", - "excerpt": "Promise.all управляет итогом набора промисов, но не владеет внешней работой. Разбираем порядок результатов, ранний reject, отдельную отмену и безопасную проверку на небольшом примере.", - "contentHtml": "

Сервис собирает профиль пользователя из трёх запросов: настройки, лимиты и историю операций. Один запрос завершается ошибкой. Обработчик сразу попадает в catch и возвращает ответ об ошибке. Через секунду другой запрос всё ещё пишет в лог, держит соединение или меняет локальное состояние.

\n

Симптом — разработчик видит отклонённый Promise.all и говорит: «набор остановился». Цена ошибки — лишняя нагрузка, гонка за общим состоянием и неверная очистка ресурсов. Код может повторить операцию, пока первый запуск ещё работает. Расследование усложняется: aggregate уже отклонён, а позднее событие живёт в другом promise.

\n

Тезис простой: Promise.all сообщает исход группы. Он не является командой отмены для входных promise и не знает, какая внешняя операция их породила. Ранний reject останавливает ожидание успешного aggregate, но не доказывает остановку работы. Для остановки нужен отдельный контракт: владелец сигнала, способ передать его операции и подтверждение результата.

\n

Механизм: три разных объекта

\n

Первый объект — входной promise. Он представляет один будущий исход: значение или ошибку. Второй — aggregate promise, который возвращает Promise.all(iterable). Он собирает значения по позиции входного iterable и принимает решение о собственном исходе. Третий — внешняя операция: HTTP-запрос, чтение файла, запрос к базе или вычисление. Она может существовать за пределами promise-модели.

\n

Связь направлена только в одну сторону. Вход сообщает aggregate, что он fulfilled или rejected. Aggregate сообщает вызывающему коду свой исход. Такая связь не содержит команды «остановись» для другого входа. Даже после reject aggregate другой вход может позже fulfilled или rejected.

\n
const left = Promise.reject(new Error('parse failed'));\nconst right = new Promise((resolve) => {\n  setTimeout(() => 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.
\n

Здесь Promise.all отклоняется из-за left. Ожидание текущей функции заканчивается. Но таймер получил отдельное право завершить right. Никакой код не передал ему сигнал отмены. Поэтому позднее значение появится, даже если вызывающий код больше его не читает.

\n

Это учебный пример с фиксированными promise и таймером. Он не измеряет задержку сети, поведение браузера или расход ресурсов в production. Он проверяет только границу между исходом aggregate и исходом другого входа.

\n
Три уровня Promise.all: входной promise, aggregate promise и внешняя операция
Aggregate фиксирует свой исход. Из этого факта нельзя автоматически вывести отмену входа или внешней операции.
\n

Что можно вывести из исхода

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но поздний лог продолжилсяДругой вход завершает собственную работуДобавить trace для каждого входаНе называть aggregate отменой; найти владельца
Значения пришли в неожиданном порядкеОжидалась скорость, а не позиция iterableСравнить индексы и порядок событийЧитать результат по позиции или хранить ключ
Нужно увидеть каждую ошибку, но видна однаPromise.all завершает aggregate на первом rejectПроверить требование к полному отчётуРассмотреть Promise.allSettled
После ошибки запросы продолжаютсяОперации не получили общий сигналПроверить API и обработчик сигналаПередать AbortSignal или другой cancel contract
Остановка считается доказанной по rejectСмешаны исход результата и управление ресурсомНазвать реальное действие прерыванияРазделить зависимую логику и протокол остановки
\n

Порядок результатов и первая ошибка

\n

При успешном исходе массив значений сохраняет порядок входного iterable, а не порядок завершения. Быстрый второй promise всё равно окажется во второй позиции. Это удобно для сопоставления, пока массив не меняется между запуском и чтением.

\n

При reject aggregate получает ошибку входа, который первым сообщил отклонение в рамках алгоритма наблюдения. Это не отчёт обо всех ошибках и не журнал времени завершения. Promise.allSettled решает другую задачу: ждёт завершения всех входов и возвращает статус каждого. Он тоже не добавляет отмену.

\n

Отмена живёт рядом, но отдельно

\n

Для операции, которая умеет принимать сигнал, отмену можно связать с обработкой aggregate. В браузерном или серверном коде это часто AbortController и его signal. Контроллер создаёт вызывающий код. Операции получают сигнал. При ошибке вызывающий код вызывает abort(). Конкретный API должен обработать сигнал по своему контракту.

\n
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) => fetch(url, { signal }).then((response) => {\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}
\n

Фрагмент ограничен операциями fetch, которые получили сигнал. Он не отменяет синхронную функцию, уже записанную транзакцию или promise, который игнорирует signal. abort() означает запрос на остановку поддерживаемой операции, а не откат каждого побочного эффекта.

\n

Проверка на маленькой трассировке

\n

Запишите события каждого уровня. Первый promise отклоняет aggregate. Затем ручной владелец второго promise вызывает сохранённый resolve. Ожидаемая последовательность показывает два факта: aggregate rejected, а другой вход позже fulfilled.

\n
const trace = [];\nlet finishRight;\n\nconst left = Promise.reject(new Error('left failed'));\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    trace.push('right fulfilled');\n    resolve('ok');\n  };\n});\n\nawait Promise.all([left, right]).catch(() => trace.push('aggregate rejected'));\nfinishRight();\nawait right;\n\nconsole.log(trace);\n// ['aggregate rejected', 'right fulfilled']
\n

Трассировка не доказывает, что реальный запрос продолжился: в ней нет запроса. Она доказывает более узкое утверждение о promise, которым мы управляем вручную. Для сетевой отмены нужен отдельный тест API, который принимает сигнал, и проверка его наблюдаемого завершения.

\n

Порядок действий

\n
  1. Назовите каждый вход и внешнюю операцию, которая его создаёт.
  2. Запишите нужный результат: все значения, первый успешный, все статусы или раннее завершение зависимой логики.
  3. Снимите отдельный trace для входов и aggregate.
  4. Проверьте отрицательный путь: после первого reject другой вход может завершиться позже.
  5. Если нужна остановка, найдите API отмены и его владельца. Передайте сигнал до запуска.
  6. Проверьте, что обработчик сигнала меняет состояние нужной операции.
  7. Только затем выбирайте Promise.all, Promise.allSettled или другой способ композиции.
\n

Ограничения модели

\n

Promise.all не обещает параллельное выполнение в смысле потоков. JavaScript может передать управление между асинхронными продолжениями, а конкретный API сам определяет выполнение. Нельзя выводить пропускную способность, порядок сетевых пакетов или освобождение ресурса из одного вызова combinator.

\n

Нельзя считать abort() универсальным откатом. Он не возвращает отправленные данные, не отменяет побочный эффект на сервере и не исправляет уже завершённую запись. Он также не заменяет дедупликацию, идемпотентность и таймаут.

\n

Нельзя выбирать allSettled только потому, что первая ошибка неудобна. Если следующий шаг требует всех значений, ранний reject не даёт начать неполный расчёт. Если нужен отчёт о каждом независимом входе, подходит all-settled-семантика. Решение зависит от зависимости между работами.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда команда отвечает на четыре вопроса: какой promise отклоняет aggregate; какой вход может завершиться позже; какое действие останавливает внешнюю работу; какое наблюдение подтверждает остановку. Учебный код готов, если trace показывает независимые исходы. Код с реальными операциями готов, если тест проверяет поддержку сигнала и отрицательный путь, где один вход отвергнут, а другой ещё выполняется.

\n

Если на вопрос об остановке отвечают «Promise.all», модель не готова. Добавьте владельца отмены или честно зафиксируйте, что операция не поддерживает остановку.

\n

Проверяемые источники

" -} \ No newline at end of file + "title": "Promise.all не отменяет работу: как разделить результат и управление", + "excerpt": "Разбираем на трассировке, что именно отклоняет Promise.all, почему соседние операции продолжаются и как связать отмену с AbortController, не выдавая запрос на остановку за откат побочных эффектов.", + "contentHtml": "

Сервис собирает профиль из настроек, лимитов и истории операций. Код запускает три запроса через Promise.all. Если настройки отвечают ошибкой, обработчик сразу получает отказ и возвращает клиенту ошибку. В это время история может ещё выполняться: соединение занято, лог продолжает поступать, а завершившийся запрос может попытаться обновить общее состояние.

\n

Такой симптом часто описывают словами «Promise.all остановил набор». Это неточное объяснение. Если его принять за контракт отмены, команда начнёт повторять сбор профиля поверх ещё работающего запуска или решит, что серверный побочный эффект уже невозможен. Правильный вопрос звучит иначе: какой объект сообщил об ошибке и каким механизмом владелец работы может остановить саму работу?

\n

Сначала разделим три уровня

\n

В этой ситуации есть три разных объекта. Входной promise представляет один будущий исход: значение или ошибку. Aggregate promise, который вернул Promise.all, представляет решение о всей группе. Внешняя операция — сетевой запрос, чтение файла, обращение к базе или вычисление — создаётся конкретным API и может иметь собственный жизненный цикл.

\n

Promise.all связывает исходы первых двух уровней. Он подписывается на входы, ждёт их успешного завершения и кладёт значения в массив по позиции входного iterable. Первый отказ переводит aggregate в состояние rejected. В этом алгоритме нет универсальной команды, которая должна остановить остальные входы или ресурс, породивший их.

\n
Матрица Promise.all: aggregate отклонён, отдельный вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены
Отклонённый aggregate — наблюдаемый исход композиции. Он не доказывает ни остановку входных promise, ни отмену внешней операции.
\n

Что гарантирует Promise.all

\n

У метода есть две полезные и проверяемые гарантии. При успехе он возвращает массив значений в порядке входного iterable, даже если второй запрос завершился раньше первого. При отказе он отклоняет возвращённый promise с причиной отказа входа, который первым отклонился. После этого ожидание aggregate заканчивается, но остальные входы всё ещё могут перейти в свой исход.

\n

Слово «первым» относится к моменту, когда отказ наблюдён для aggregate, а не к позиции элемента и не к полному отчёту о всех ошибках. Последующие входы всё равно получают обработчики через внутреннюю композицию. Поэтому поздний отказ не обязан превращаться в отдельное необработанное исключение, а позднее выполнение не исчезает только потому, что вызывающий код перестал ждать aggregate.

\n
const 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 уже отклонён, когда два других входа ещё не завершились.

\n

Это намеренно маленький тест. Таймер не моделирует TCP-соединение, запрос к базе или отмену на сервере. Он воспроизводит только границу между состоянием aggregate и состояниями уже запущенных входов.

\n

Порядок значений не равен порядку завершения

\n

Результат успешного вызова сопоставляется с исходным массивом, а не со скоростью задач. Вызов Promise.all([loadSettings(), loadHistory()]) вернёт настройки в позиции 0, историю в позиции 1, даже если история пришла раньше. Это делает композицию удобной, пока список входов не меняется между запуском и чтением.

\n

Если нужны статусы всех независимых операций, выбирайте Promise.allSettled. Он ждёт завершения каждого входа и возвращает элементы вида { status: 'fulfilled', value } или { status: 'rejected', reason }. Этот метод меняет форму отчёта, но не добавляет отмену: ни all, ни allSettled не знают, как остановить произвольную работу.

\n

Выбор комбинатора — это решение о зависимости

\n
Какой результат нужен вызывающему коду
МетодКогда выбиратьЧто возвращаетЧего не делает
Promise.allНужны все значения, и один отказ делает результат непригоднымМассив значений по позициям или первая причина отказа aggregateНе отменяет остальные операции
Promise.allSettledКаждый вход нужно учесть независимо от его исходаМассив статусов всех входов после их завершенияНе отменяет и не исправляет побочные эффекты
Promise.raceНужен первый завершившийся исход, например гонка с таймеромЗначение или ошибка первого settled promiseТаймером нельзя отменить проигравшую работу
Promise.anyДостаточно первого успешного результатаПервое fulfilled-значение или AggregateError, если все отказалиНе останавливает остальные попытки
\n

Например, таймаут через Promise.race меняет то, что увидит вызывающий код, но сам по себе не выключает медленный запрос. Если запрос дорогой или меняет данные, таймаут должен идти вместе с реальным контрактом отмены либо с отдельной защитой от повторной операции.

\n

Как добавить отмену для fetch

\n

Отмена должна иметь владельца и канал связи. В браузерном Fetch API таким каналом служит AbortSignal: вызывающий код создаёт AbortController, передаёт его сигнал каждой поддерживаемой операции и вызывает abort(), когда группа больше не нужна. Сам Promise.all при этом остаётся только агрегатором.

\n
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() есть короткая гонка: операция могла уже завершиться, поэтому код не должен обещать, что каждый запрос будет остановлен.

\n

В прикладном коде контроллер часто принадлежит экрану, обработчику запроса или задаче верхнего уровня. Тогда функция принимает внешний signal, а не создаёт скрытый контроллер, чтобы закрытие страницы или отмена родительской задачи тоже дошли до fetch. Контракт следует описать явно: кто вызывает abort(), какие API принимают сигнал и какое состояние видит вызывающий код после отмены.

\n

Где AbortController не спасает

\n

AbortController работает только там, где операция действительно слушает переданный сигнал. Он не прерывает синхронный цикл, обычный promise с игнорируемым аргументом, уже отправленную сервером команду или запись, которая успела зафиксироваться до отмены. Для базы данных, очереди и внешнего сервиса нужен их собственный протокол: отмена запроса, дедлайн, идемпотентный ключ или компенсационное действие.

\n

Отмена также не равна откату. Клиент может прекратить ждать тело ответа, а сервер уже успеть списать деньги, создать задачу или записать событие. Поэтому границу побочных эффектов проверяют на стороне исполнителя. Для повторных запусков добавляют идемпотентность и корреляционный идентификатор; для диагностики логируют начало, отмену, успешное завершение и причину отказа каждой операции.

\n
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) => {\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

Диагностика симптома в проекте

\n
Трассировка вместо предположения
НаблюдениеГипотезаПроверкаСледующее действие
Общий catch сработал, но поздний лог продолжаетсяДругой вход ещё выполняетсяДобавить started, fulfilled, rejected и cancelled с request idРазделить исход aggregate и жизненный цикл операции
Результаты «перепутались»Сопоставили скорость завершения с позицией массиваВывести имя входа и его индексЧитать массив по позиции или возвращать объект с ключом
Видна только одна ошибкаВыбран fail-fast отчётПроверить, нужен ли полный список статусовДля независимых входов рассмотреть allSettled
После отказа лишние запросы расходуют ресурсОперации не получили общий сигналПроверить сигнатуру API и тест отменыПередать AbortSignal или реализовать собственный cancel contract
После abort данные всё равно изменилисьСерверный эффект уже произошёлСверить корреляционный id с журналом исполнителяДобавить идемпотентность или компенсацию, а не обещать откат
\n

Воспроизводимая проверка перед изменением кода

\n
  1. Перечислите входные promise и реальную операцию за каждым из них.
  2. Зафиксируйте требование: нужны все значения, все статусы, первый успех или только ограничение времени ожидания.
  3. Запустите детерминированную трассировку с одной ранней ошибкой и двумя поздними завершениями.
  4. Проверьте порядок массива отдельно от порядка логов: эти последовательности отвечают на разные вопросы.
  5. Если требуется остановка, найдите API отмены и передайте сигнал до старта операции.
  6. Напишите отрицательный тест: один вход отклоняется, а остальные либо получают отмену, либо честно продолжают работу по задокументированному контракту.
  7. Проверьте побочный эффект на стороне исполнителя. Клиентский отказ promise не доказывает, что сервер забыл уже принятую команду.
  8. Только после этого выберите Promise.all, Promise.allSettled, race или any.
\n

Границы применимости

\n

Promise — это модель будущего значения и его исхода, а не поток и не диспетчер ресурсов. Из самого факта совместного запуска нельзя вывести число потоков, порядок сетевых пакетов, освобождение соединения или нагрузку на сервер. Эти свойства определяются средой исполнения и конкретным API.

\n

Ранний отказ aggregate подходит, когда зависимый следующий шаг больше нельзя выполнять с неполным набором данных. Он не подходит как единственный механизм остановки дорогих независимых задач. allSettled полезен для отчёта о независимых результатах, но не превращает ошибки в успех и не скрывает необходимость очистки.

\n

Пример с fetch применим к операциям, которые принимают AbortSignal. Пример с собственной функцией применим только после того, как операция реально проверяет сигнал и освобождает свой ресурс. Для CPU-bound вычисления, транзакции и внешней очереди нужны отдельные ограничения и тесты.

\n

Проверяемые источники

\n

Итоговая проверка проста: назовите promise, который отклоняет aggregate; назовите работу, которая может продолжиться; назовите владельца сигнала; покажите тест, подтверждающий остановку или её отсутствие. Если на последний вопрос отвечают только «сработал catch», в коде смешаны два разных контракта.

" +} diff --git a/editorial/agent-rewrites/081.json b/editorial/agent-rewrites/081.json index 3141d1f..62766dd 100644 --- a/editorial/agent-rewrites/081.json +++ b/editorial/agent-rewrites/081.json @@ -2,6 +2,6 @@ "index": 81, "slug": "editorial-2025-10-practice-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", - "excerpt": "Promise.all объединяет результаты асинхронных операций, но не управляет их остановкой. Разбираем модель, контрпример, проверку и отдельный контракт отмены.", - "contentHtml": "

Симптом появляется после первой же ошибки: один запрос отклонился, обработчик получил исключение, а соседние запросы продолжают выполняться. В логах уже виден общий отказ, но база данных, сетевой клиент или внешний сервис ещё получают работу. Цена ошибки — лишний трафик, ненужные записи, гонки при очистке и неверное сообщение пользователю. Если эту границу объяснить неточно, команда начнёт искать отмену в месте, которое умеет только ждать.

\n

Тезис простой: Promise.all описывает исход группы promises, а не жизненный цикл операций, которые за ними стоят. Aggregate promise может отклониться на первой ошибке. Остальные входы при этом не получают приказ остановиться. Значит, ожидание и отмена — два разных контракта. Их нужно проектировать и проверять раздельно.

\n

Модель: что именно объединяет Promise.all

\n

Представьте два входа: left и right. Каждый из них обещает значение в будущем. Вызов Promise.all([left, right]) создаёт третий promise. Он становится успешным, когда все входы успешны, и отклоняется, когда один вход отклоняется. При успехе значения идут в том же порядке, что и входы.

\n

Третий promise не владеет внутренней работой left и right. Он наблюдает их состояния и сообщает общий результат. Если left завершился ошибкой, aggregate promise может завершиться сразу. Уже начатый right не обязан завершаться в тот же момент. Если right сам изменяет состояние внешней системы, наблюдение за ним не откатывает это изменение.

\n
Схема: два входных promise сходятся в общий результат, а отмена остаётся отдельным контрактом
Общий promise сообщает результат группы. Отдельный сигнал отмены управляет конкретной операцией, если такой сигнал предусмотрен её API.
\n

Минимальный пример с видимым порядком событий

\n

Учебный пример ниже не обращается к сети, файлам или базе. Он вручную завершает второй promise после того, как общий promise уже отклонился. Так видна только семантика ожидания. Пример не доказывает, что какой-либо реальный клиент продолжит работу именно с такой задержкой.

\n
let 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. Ожидание результата группы не стало командой отмены.

\n

Важна и другая деталь. Promise.all не создаёт сами операции. К моменту вызова promises часто уже запущены: функция вернула promise, сетевой запрос уже отправлен, чтение уже поставлено в очередь. Обёртка видит результат этой работы, но не получает универсального доступа к её внутренним ресурсам.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок вокруг Promise.all
СимптомПричинаПроверкаДействие
После первой ошибки соседний запрос всё ещё виден в логахAggregate promise сообщает отказ, но не отменяет входДобавить отдельные метки старта и завершения каждой операцииРешить, нужна ли отмена, и передать сигнал в API операции
Код ловит общий отказ и считает работу законченнойСмешаны состояние aggregate promise и состояние ресурсовПроверить, что происходит с каждым входом после catchДождаться нужных cleanup-действий или описать их отдельный контракт
Пользователь нажал «отмена», но запрос продолжилсяКнопка меняет интерфейс, а сигнал не дошёл до транспортаПроверить прохождение AbortSignal до вызова APIСвязать действие пользователя с поддерживаемым механизмом отмены
После ошибки меняется общий объектОдна из операций имеет побочный эффектСравнить состояние до запуска, после отказа и после завершения остальных входовСделать операцию идемпотентной, добавить компенсацию или изменить порядок
В сообщении указано «все запросы остановлены»Вывод сделан по первой ошибке, а не по наблюдению ресурсовНайти подтверждение остановки для каждого участника группыСузить формулировку до «общий результат отклонён»
\n

Как выглядит отдельная отмена

\n

Отмена появляется только там, где её поддерживает исполнитель. Для браузерного fetch обычно передают AbortSignal, полученный от AbortController. Контроллер не превращает любой promise в отменяемый. Он передаёт сигнал в конкретный API, а API решает, как остановить или прервать свою работу.

\n
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 уже успел изменить серверное состояние, прекращение ожидания ответа не отменит это изменение. Для серверной операции нужен серверный контракт: ключ идемпотентности, отмена задания, транзакция или компенсационное действие.

\n

Не следует использовать контроллер как универсальный откат. Он помогает остановить поддерживаемую передачу данных. Он не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную функцию, которая просто вернула promise.

\n

Положительный и отрицательный пути

\n

Положительный путь начинается с успешных входов. Тогда aggregate promise возвращает массив значений в порядке входного массива. Это удобно, когда зависимый код может начать работу только после получения всех значений. Проверка должна подтвердить не только наличие двух ответов, но и соответствие позиции: первый результат относится к первому входу.

\n

Отрицательный путь начинается с отклонения одного входа. Aggregate promise сообщает ошибку, но у приложения остаются вопросы. Нужно ли дождаться остальных результатов? Нужно ли отменить их? Можно ли показать частичные данные? Нужна ли компенсация побочного эффекта? На эти вопросы Promise.all не отвечает. Если нужен итог каждого входа, включая ошибки, применяют Promise.allSettled. Если нужна остановка, добавляют API отмены. Если нужна повторная попытка, описывают её лимит и область действия отдельно.

\n

Отдельно проверяйте поздние события. Отмена может прийти после ответа сервера. Таймаут может сработать после записи на сервере. Первый отказ может появиться раньше, чем второй запрос заметит закрытый сигнал. Обработчик должен различать «мы перестали ждать», «транспорт получил отмену» и «внешняя операция действительно прекратилась». Это три разных наблюдения.

\n

Порядок проверки

\n
  1. Опишите каждую операцию отдельно: что она читает, что меняет и какой ресурс должен остановиться.
  2. Зафиксируйте ожидаемый общий результат: все значения, первая ошибка или полный набор исходов.
  3. Запустите два входа с разными задержками и запишите отдельные события старта, отказа, завершения и отмены.
  4. Проверьте сценарий, в котором первый вход отклоняется, а второй завершается позже.
  5. Если нужна отмена, передайте поддерживаемый сигнал в каждый исполнитель и проверьте его получение.
  6. Проверьте поздний ответ после отмены: он не должен менять UI или состояние, если операция уже устарела.
  7. Проверьте побочные эффекты отдельно: отмена ожидания не должна называться откатом без подтверждения внешней системы.
\n

Ограничения модели

\n

Эта модель объясняет семантику группы promises, но не задаёт политику приложения. Она не решает, какой запрос важнее, как долго ждать, сколько раз повторять и как сообщать частичный результат. Для этих решений нужны отдельные правила и наблюдаемые сигналы.

\n

Она также не заменяет документацию конкретной библиотеки. Один клиент может поддерживать AbortSignal, другой — собственный метод отмены, третий — только закрытие соединения. Нельзя переносить поведение одного транспорта на другой по одному имени promise.

\n

Учебный код с ручным finishRight показывает порядок переходов в памяти. Он не является нагрузочным тестом, не измеряет задержки и не подтверждает поведение production-системы. В реальном сервисе проверяйте контракт библиотеки, логи транспорта и состояние внешнего ресурса.

\n

Критерий готовности

\n

Объяснение и код готовы, если читатель может ответить на четыре вопроса без догадки: какой promise сообщает общий результат, что происходит с остальными входами после первой ошибки, какой объект или API действительно принимает отмену и что происходит с уже выполненным побочным эффектом. В проверочном запуске должны быть видны отдельные события для aggregate promise и каждой операции. Для сценария отмены нужно подтвердить получение сигнала исполнителем, а для серверного изменения — отдельное подтверждение остановки или компенсации.

\n

Если на вопрос об остановке отвечают только названием Promise.all, объяснение не готово. Добавьте контрпример и укажите владельца отмены. Если такого владельца нет, честный результат звучит так: общий promise отклонился, а начатая работа может продолжиться.

\n

Проверяемые источники

" + "excerpt": "Promise.all объединяет исходы асинхронных операций, но не управляет их остановкой. Разбираем порядок результатов, контрпример, AbortSignal и проверку, которую можно воспроизвести локально.", + "contentHtml": "

Симптом появляется после первой же ошибки: один запрос отклонился, обработчик попал в catch, а соседний запрос продолжил выполняться. В логах уже виден общий отказ, но сетевой клиент, чтение файла или внешний сервис ещё получает работу. Цена ошибки — лишний трафик, гонка за общим состоянием и сообщение пользователю, которое не совпадает с реальным состоянием ресурсов.

\n

Главная граница такова: Promise.all сообщает исход группы promises, но не является командой остановки. Aggregate promise отклоняется при первом отклонении входа, однако остальные входы не получают от этого сигнала отмены. Поэтому в объяснении нужно разделять три вещи: исход aggregate, исход каждого входного promise и состояние внешней операции, которая за ним стоит.

\n

Что именно объединяет Promise.all

\n

Вызов Promise.all(iterable) создаёт новый promise. Если каждый элемент iterable успешно завершился, новый promise получает массив значений. Позиции массива соответствуют позициям входов, а не скорости их завершения. Если один вход отклоняется, aggregate отклоняется с причиной этого отклонения и больше не ждёт успешного результата группы.

\n

При этом Promise.all не запускает отмену и не откатывает побочные эффекты. К моменту вызова функции вроде fetchProfile() или readFile() работа обычно уже началась. Комбинатор добавляет обработчики к полученным promises и собирает их исходы; у него нет универсального доступа к сокету, таймеру, файловому дескриптору или транзакции.

\n
Матрица: aggregate promise отклонён, оставшийся вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены
Схема разделяет уровень aggregate, оставшийся вход и внешнюю операцию. Из красной колонки нельзя делать вывод об остановке без отдельного API.
\n

Контрпример с воспроизводимым порядком

\n

Сначала проверим только семантику promises, без сети и базы данных. Первый вход отклоняется сразу, второй вручную завершается позднее. Запустите фрагмент в Node.js с поддержкой ES-модулей: команда не требует пакетов и показывает два независимых события.

\n
node --input-type=module <<'EOF'\nlet finishRight;\n\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    console.log('right fulfilled');\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('aggregate rejected');\n});\n\nawait combined;\nfinishRight();\nawait right;\nEOF
\n

Ожидаемый вывод: сначала aggregate rejected, затем right fulfilled. Второй promise не «забыт»: у него остаётся собственный обработчик и возможность перейти из pending в fulfilled. Пример не имитирует поведение сетевого транспорта и не измеряет освобождение ресурсов. Он доказывает более узкую границу: отклонение результата группы не меняет состояние другого promise.

\n

Порядок значений проверяется отдельно. Если быстрый запрос стоит вторым, он всё равно попадёт во вторую позицию успешного массива. Поэтому при деструктуризации вроде const [profile, limits] = await Promise.all([...]) порядок массива должен быть закреплён тестом или заменён явными ключами.

\n

Симптом, проверка и действие

\n
Как не перепутать исход группы с остановкой работы
СимптомЧто реально произошлоПроверкаДействие
catch сработал, а поздний лог продолжилсяAggregate уже отклонён, другой вход ещё выполняетсяДобавить метки старта и завершения для каждого входаНе называть общий отказ отменой; найти API остановки
Результаты перепуталисьПорядок входного iterable принят за порядок завершенияЗаписать имя операции рядом с индексом результатаЗакрепить порядок или возвращать объект с ключами
Видна только одна ошибкаPromise.all сообщает первый отказ, а не полный отчётПроверить, нужен ли исход каждого входаДля полного отчёта рассмотреть Promise.allSettled
После ошибки соседний fetch продолжилсяЗапрос не получил сигнал или исполнитель его не поддерживаетПроверить передачу signal до транспортаПередать общий AbortSignal и проверить событие отмены
После отмены серверная запись осталасьОстановлено ожидание ответа, а не уже выполненный побочный эффектПроверить состояние на сервере отдельным запросомИспользовать идемпотентность, транзакцию или компенсацию
\n

Положительный путь и полный отчёт

\n

Для независимых чтений Promise.all подходит, когда следующий шаг требует всех значений. Например, экран можно построить только после получения профиля и лимитов. Вызовы функций ставят работу в очередь до ожидания aggregate: Promise.all([loadProfile(), loadLimits()]). Передача самих функций — ошибка: Promise.all([loadProfile, loadLimits]) передаст обычные значения-функции, а не результаты их вызова.

\n

Если нужны все исходы, включая ошибки, выбирайте другой контракт. Promise.allSettled ждёт, пока каждый вход завершится, и возвращает для каждого статус fulfilled или rejected. Это не добавляет отмену и не делает операции безопаснее; метод лишь меняет то, когда и в каком виде приложение получает отчёт.

\n

Если работы зависят друг от друга, не следует искусственно объединять их в один массив. Последовательный код с отдельными проверками может быть понятнее и дешевле: сначала получить идентификатор, затем запросить данные по нему. Параллельный запуск оправдан только для действительно независимых операций и при принятом риске их одновременного выполнения.

\n

Отмена живёт у исполнителя

\n

Отмена появляется там, где конкретный исполнитель принимает сигнал. Для браузерного fetch это обычно AbortController и его signal. Один контроллер можно передать нескольким запросам. Вызов abort() отправит сигнал каждому поддерживаемому запросу, но не превратит произвольную функцию, таймер или уже выполненную запись в отменяемую операцию.

\n
async function loadDashboard() {\n  const controller = new AbortController();\n  const { signal } = controller;\n\n  const json = (url) => fetch(url, { signal }).then((response) => {\n    if (!response.ok) {\n      throw new Error(url + ': HTTP ' + response.status);\n    }\n    return response.json();\n  });\n\n  try {\n    return await Promise.all([\n      json('/api/profile'),\n      json('/api/limits'),\n    ]);\n  } catch (error) {\n    controller.abort();\n    throw error;\n  }\n}
\n

Здесь вторая проверка намеренная. fetch обычно выполняется успешно на уровне promise даже при HTTP 404 или 500: сервер ответил, поэтому нужно самостоятельно проверить response.ok или response.status. Если проверка обнаружит HTTP-ошибку, catch вызовет abort() для ещё работающего соседа.

\n

Фрагмент гарантирует только контракт поддерживаемых fetch-запросов. Успевший запрос может уже получить ответ до вызова abort(). Сервер мог принять данные до отмены клиента. Поэтому «клиент перестал ждать» и «сервер отменил действие» — разные результаты, которые проверяются разными наблюдениями.

\n

Сигнал нельзя повторно использовать как возобновляемый ресурс: после отмены он остаётся aborted, а новый запрос с ним будет отклонён. Для нового запуска создайте новый контроллер. Таймаут можно выразить через AbortSignal.timeout(milliseconds), но проверьте поддержку в целевых браузерах и отдельно обработайте причину таймаута, если интерфейсу нужно отличать её от действия пользователя.

\n

Как проверить границу в своём коде

\n

Проверка должна наблюдать не только состояние aggregate. Для каждого входа запишите имя операции, время старта, время завершения, причину отказа и факт получения сигнала. Если операция меняет внешний ресурс, добавьте проверку состояния ресурса после отказа. Лог «Promise.all отклонён» сам по себе доказывает только исход aggregate.

\n
  1. Назовите каждую операцию и запишите, читает ли она данные, меняет состояние или делает и то и другое.
  2. Определите ожидаемый контракт: все успешные значения, первый отказ, полный набор статусов или отмена оставшихся операций.
  3. Запустите два входа с разными задержками и сохраните отдельные события старта, отказа и завершения.
  4. Проверьте отрицательный путь: первый вход отклоняется, второй завершается позже и не получает автоматической команды остановиться.
  5. Если нужна отмена, передайте сигнал в каждый поддерживаемый исполнитель до запуска и вызовите abort() из явной политики: кнопки, таймаута или обработчика ошибки.
  6. Проверьте, что обработчик отличает обычную ошибку, AbortError и таймаут, если среда предоставляет разные причины.
  7. Проверьте поздний результат: устаревший ответ не должен перезаписывать новый экран или новое состояние.
  8. Для побочного эффекта запросите подтверждение внешней системы; не заменяйте его фактом отмены клиентского ожидания.
\n

Ограничения применимости

\n

Эта модель описывает стандартный combinator, а не политику приложения. Она не решает, сколько запросов можно выполнять одновременно, сколько раз повторять ошибку, что показывать при частичных данных и кто владеет отменой. Эти правила должны быть явными в коде и тестах.

\n

Promise.all не означает параллельные потоки. JavaScript-движок и конкретный API сами определяют, как выполняется работа. Из успешного aggregate нельзя вывести пропускную способность, порядок сетевых пакетов или момент освобождения соединения. Для таких утверждений нужны метрики и документация транспорта.

\n

Отмена не равна откату. Она может прекратить поддерживаемый fetch, чтение тела ответа или поток, но не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную синхронную функцию. Серверную операцию защищают отдельные механизмы: идемпотентный ключ, транзакция, отменяемое задание или компенсационное действие.

\n

Пример с ручным finishRight проверяет переходы promises в памяти. Пример с fetch проверяет передачу сигнала клиентскому API, но не гарантирует поведение конкретного прокси или сервера. Для старого браузера, Node.js или библиотеки с собственным клиентом сверяйте документацию именно этой версии.

\n

Критерий готовности объяснения

\n

Материал и реализация готовы, если читатель может ответить на четыре вопроса: какой promise сообщает общий результат; что делает другой вход после первой ошибки; какой объект или API реально принимает отмену; что происходит с уже выполненным побочным эффектом. В тестовом запуске должны быть видны отдельные события aggregate и каждой операции.

\n

Минимальный набор проверок содержит положительный сценарий с массивом значений, отрицательный сценарий с поздним входом и сценарий с поддерживаемым AbortSignal. Если доказательством остановки служит только catch, проверка неполна. Честный вывод в таком случае: aggregate отклонён, а начатая работа может продолжиться.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/082.json b/editorial/agent-rewrites/082.json index 9da236c..8ca3794 100644 --- a/editorial/agent-rewrites/082.json +++ b/editorial/agent-rewrites/082.json @@ -2,6 +2,6 @@ "index": 82, "slug": "editorial-2025-09-field-engineering-interviews", "title": "Как калибровать техническое интервью по фактам, а не по впечатлению", - "excerpt": "Два reviewer-а могут прочитать один технический ответ по-разному. Разбираем, как найти первую точку расхождения на synthetic-пробе, проверить её и остановить опасный переход от учебной записи к выводу о человеке.", - "contentHtml": "

Два reviewer-а читают один технический ответ. Один видит аккуратную гипотезу. Другой — недостаток глубины. Через пять минут спор уже идёт о впечатлении, а не о тексте. В журнале нет ссылки на строку, где началось расхождение.

\n

Цена ошибки высока. Команда меняет rubric после самого громкого мнения. Кандидат или сотрудник получает вывод, который нельзя проверить. Следующий review повторяет тот же спор. Калибровка не исправляет это обсуждением в конце встречи. Она должна сначала зафиксировать одинаковый вход, независимые факты и границу того, чего проба не показывает.

\n

Тезис: технический review калибруется через цепочку «наблюдение → неизвестное → интерпретация → следующий запрос». Reviewer-ы не обязаны прийти к одинаковой интерпретации. Они обязаны показать, на каких фактах она стоит и где заканчивается evidence. Если в записи появляется score, ranking или вывод о личности, процесс должен остановиться.

\n

Механизм: разделить факт и объяснение

\n

Возьмём учебную карточку с ответом на вопрос об устаревшем API-ответе. В тексте есть max-age=0, маршрут /v1/report и описание симптома. Правило origin-сервера не указано. Это намеренный пробел. Он позволяет проверить, умеет ли reviewer отделить видимое от неизвестного.

\n

Observation отвечает только на вопрос «что видно в sample?». Например: «В заголовке указан max-age=0». Unknown отвечает на вопрос «чего здесь нет?»: «Правило, по которому origin формирует cache-control, не показано». Interpretation связывает эти записи: «Нужен запрос правила origin; изменение cache policy пока не доказано». Такой порядок не запрещает гипотезы. Он не даёт гипотезе притвориться фактом.

\n
СлойДопустимая записьЗапрещённый скачок
ObservationВ sample есть max-age=0.«Сервис неправильно настроен».
UnknownOrigin rule не показано.«Автор не понимает кеширование».
InterpretationНужно запросить правило origin.«Reviewer B глубже разобрался».
Hand-offЗапросить один отсутствующий факт.Создать score, ranking или hiring outcome.
\n

Независимость нужна до обсуждения. Каждый reviewer получает тот же sampleId, ту же role rubric и те же критерии. Он сначала пишет факты и неизвестные, затем интерпретацию. Если начать с общей беседы, участники быстро выровняют формулировки, но потеряют момент, где они увидели разное.

\n

Конкретный пример: безопасный hand-off

\n

Ниже — учебный TypeScript-подобный код. Он работает с объектом в памяти. Он не обращается к сети, не пишет файл и не создаёт оценку человека. В production этот пример ничего не доказывает.

\n
type ReviewRecord = {\n  sampleId: string;\n  criterionId: string;\n  evidenceRef: string;\n  kind: 'observation' | 'unknown' | 'interpretation' | 'hand-off';\n  text: string;\n};\n\nfunction acceptHandOff(record: ReviewRecord) {\n  const safePrefix = 'hand-off:';\n  const forbidden = /score|ranking|hiring|person|candidate/i;\n\n  if (record.kind !== 'hand-off' || !record.text.startsWith(safePrefix)) {\n    return { accepted: false, action: 'stop-and-repair-boundary' };\n  }\n  if (forbidden.test(record.text)) {\n    return { accepted: false, action: 'stop-and-repair-boundary' };\n  }\n  return { accepted: true, action: 'request-missing-evidence' };\n}\n\nconst next = acceptHandOff({\n  sampleId: 'api-cache-fixed-v1',\n  criterionId: 'evidence-boundary',\n  evidenceRef: 'sample.headers.cache-control',\n  kind: 'hand-off',\n  text: 'hand-off: request the origin cache rule',\n});
\n

Вызов возвращает учебный запрос недостающего evidence. Он не разрешает менять конфигурацию. Ветка с текстом hiring: reject должна вернуть stop-and-repair-boundary. Это отрицательный путь, а не дополнительная функция: он показывает, что процесс заметил незаконное расширение задачи.

\n

Проверять нужно не только результат функции. Сверьте четыре поля: один sampleId, существующий evidenceRef, применимый criterionId и допустимый тип hand-off. Пустая ссылка, общий ярлык или другой sample делают запись непроверяемой. В таком случае правильное действие — остановка, а не попытка угадать недостающий факт.

\n
\"Цикл
Учебный цикл возвращает неопределённость в проверяемый запрос. Стрелка stop блокирует personal outcome и production effect.
\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Reviewer-ы спорят о «глубине».Критерий не описывает наблюдаемое поведение.Найдите строку sample, на которую ссылается каждый.Сузьте criterion до проверяемого признака.
Оба reviewer-а используют одинаковые слова, но делают разные выводы.Они смешали observation и interpretation.Перепишите записи в два отдельных поля.Запросите недостающий факт вместо решения.
В hand-off появляется score или ranking.Учебная проба пересекла границу оценки человека.Проверьте текст и допустимые типы записи.Верните stop-and-repair-boundary; не сохраняйте outcome.
После калибровки меняют сразу sample, rubric и инструкцию.Нельзя понять, что исправило расхождение.Сопоставьте первую непарную запись с изменённым артефактом.Измените один артефакт и повторите тот же sample.
Reviewer просит данные из сети или реального разговора.Проба не содержит нужного evidence и маскирует это.Проверьте boundary и список разрешённых источников.Остановите прогон или замените sample на явно новый учебный кейс.
\n

Порядок короткой calibration session

\n
  1. Заморозьте вход. Создайте synthetic sample с идентификатором, фиксированными литералами и описанием того, чего в нём нет.
  2. Опишите критерий. Запишите observable behaviour и исключённые измерения. Слова «сильный», «слабый» и «системный» без признака не подходят.
  3. Раздайте одинаковые условия. Передайте reviewer-ам один sample, одну rubric и одинаковый порядок чтения. Не начинайте с общей дискуссии.
  4. Соберите независимые записи. Каждый reviewer фиксирует observation, unknown и interpretation с ссылками на evidence.
  5. Найдите первую развилку. Сначала сравните sample id и факты. Затем сравните criterion. Только после этого обсуждайте interpretation.
  6. Классифицируйте расхождение. Разные факты указывают на проблему sample или чтения. Одинаковые факты и разные трактовки указывают на rubric. Разные hand-off указывают на неясную границу действия.
  7. Измените один артефакт. Исправьте sample, criterion или инструкцию, но не все сразу. Старую версию оставьте как учебный контрпример без связи с человеком.
  8. Повторите тот же прогон. Убедитесь, что прежняя развилка стала видимой, а hand-off по-прежнему не создаёт score, ranking, personal record или production effect.
\n

Когда этот метод не подходит

\n

Synthetic-проба проверяет процесс чтения и границу доказательства. Она не измеряет производительность инженера, качество найма, способность работать в команде или результат реального проекта. Нельзя переносить её вывод на человека. Нельзя называть отсутствие расхождения доказательством валидности rubric.

\n

Один fixed sample быстро устаревает. Изменился API, критерий или рабочая задача — изменился и смысл пробы. Набор из нескольких sample требует отдельного дизайна: иначе команда начнёт сравнивать разные задачи как одну шкалу. Если нужен реальный отбор, его должны спроектировать владельцы процесса с учётом применимых требований. Учебный журнал не заменяет такую процедуру.

\n

Метод также не спасает от плохого критерия. Если criterion требует «понять намерение автора», его нельзя проверить ссылкой на текст. Если sample скрывает несколько причин, reviewer-ы будут расходиться по делу, а не из-за плохого review. Сначала уменьшите область утверждения. Потом добавляйте сложность.

\n

Проверяемый критерий готовности

\n

Сессия готова, если независимые записи можно открыть без устного пояснения и ответить на четыре вопроса: какой sample читали; какой факт увидели; какое неизвестное осталось; почему hand-off разрешён или остановлен. Для каждого расхождения указан один артефакт, который изменили. Повторный прогон использует тот же идентификатор пробы и не создаёт score, ranking, personal outcome, сетевой запрос или production effect.

\n

Минимальная проверка — прогнать положительную и отрицательную ветки. Положительная ветка принимает только hand-off с ссылкой на существующий evidence и запросом одного недостающего факта. Отрицательная ветка отклоняет текст с оценкой человека, невалидную ссылку и неизвестный критерий. Если хотя бы одна ветка проходит без явного результата, материал не готов к использованию даже как учебный пример.

\n

Проверяемые источники

" + "excerpt": "Практический разбор калибровки технического интервью: как разделить наблюдение, неизвестное и трактовку, найти первую точку расхождения и не превратить учебную пробу в необоснованный вывод о человеке.", + "contentHtml": "

Два инженера разбирают один и тот же ответ о протухшем API-кеше. Один пишет: «в заголовке есть max-age=0». Второй сразу заключает: «автор не умеет работать с кешированием». Спор начинается не с текста и не с проверяемого критерия, а с впечатления. Через пять минут команда уже обсуждает, кто из рецензентов «глубже понял» ответ.

\n

Такая ошибка ломает сам объект калибровки. Учебная проба должна показать, одинаково ли рецензенты читают заданный признак и одинаково ли понимают рубрику. Она не должна тайком превращаться в рейтинг человека. Поэтому сначала фиксируем один вход, затем отделяем наблюдение от неизвестного и только после этого разрешаем трактовку. Любой переход к личностному выводу или решению по человеку — стоп-сигнал.

\n

Главный вопрос статьи: как обнаружить первую точку расхождения и вернуть разговор к evidence — конкретному фрагменту, который можно открыть и проверить. Ниже — учебный контракт записи, запускаемый пример на Node.js, таблица диагностики и границы применимости.

\n

Симптом: рецензенты спорят не о строке, а о «глубине»

\n

Начните с маленькой фиксированной пробы. В ней есть ответ на вопрос об API, один фрагмент заголовка и явно пропущенное правило сервера. Например, в sample указано cache-control: max-age=0, но не показано, кто и по какой конфигурации сформировал этот заголовок. Отсутствующий факт — часть входа, а не приглашение его додумывать.

\n

Запись первого рецензента может выглядеть так: «sample.headers.cache-control содержит max-age=0; правило origin не представлено; нужно запросить его». Это наблюдение, неизвестное и следующий запрос. Запись «сервис неправильно настроен» уже выходит за пределы пробы: заголовок виден, причина его появления — нет.

\n

До общей встречи сохраните идентификатор пробы, версию рубрики и ссылки на evidence. Рецензенты читают один и тот же набор и пишут независимо. Если обсуждение начнётся раньше фиксации записей, участники синхронизируют формулировки и сотрут сам момент расхождения.

\n

Механизм: четыре разных типа записи

\n

Калибровка становится проверяемой, когда у записи есть один тип и одна функция. Observation описывает то, что можно открыть в sample. Unknown называет отсутствующее условие. Interpretation связывает факт с гипотезой, но помечает её как гипотезу. Hand-off передаёт ровно один безопасный запрос на следующий факт.

\n
ТипЧто хранитПримерЧто нельзя выводить
ObservationТочное наблюдение со ссылкойsample.headers.cache-control равно max-age=0Сервис настроен неверно
UnknownНедостающий факт или условиеПравило origin не показаноАвтор не знает кеширование
InterpretationГипотеза, привязанная к фактамНужно проверить источник заголовкаРецензент умнее другого
Hand-offОдин следующий запросrequest: origin cache ruleScore, ranking или решение по человеку
\n

Здесь «ссылка» — не обязательно URL. Это стабильный путь к полю фикстуры, номер строки ответа или идентификатор артефакта. Ссылка должна приводить к существующему объекту. Пустой ярлык вроде ответ кандидата не позволяет повторить проверку и потому не является evidence.

\n

Не смешивайте калибровку с оценкой качества самого критерия. Фраза «проверяет системное мышление» недостаточна, пока не описано наблюдаемое действие: например, «называет отсутствующее условие и формулирует следующий безопасный запрос». Сначала проверяем, можно ли увидеть признак в тексте. Потом обсуждаем, нужен ли этот признак для конкретной роли.

\n

Контракт hand-off: что разрешено передать дальше

\n

Безопасный hand-off имеет четыре обязательных поля: sampleId, criterionId, evidenceRef и короткий текст запроса. Валидатор обязан сверить их со справочниками текущей пробы. Одного префикса hand-off: недостаточно: такой префикс может стоять у любой строки, включая запрещённое решение.

\n

В этом примере разрешены только два критерия и две ссылки, заранее перечисленные в фикстуре. Проверка возвращает причину отказа, а не бросает её в общий лог. Это позволяет увидеть, почему запись остановилась: неизвестный критерий, отсутствующий evidence или попытка передать личный outcome.

\n
Схема калибровки фиксированной пробы: одинаковый sample проходит независимые наблюдения, затем журнал расхождений и ограниченный hand-off; личностный вывод блокируется
Сначала фиксируется общий вход, затем сравниваются независимые записи. Неизвестное превращается в запрос доказательства, а выход к решению по человеку блокируется.
\n

Воспроизводимый пример на Node.js

\n

Скопируйте код в файл calibration.mjs и запустите на Node.js 18 или новее. Пример не обращается к сети, не читает персональные данные и не оценивает реального человека. Он проверяет только форму записи в памяти; это полезная защита границы, но не доказательство валидности интервью.

\n
const allowedCriteria = new Set(['evidence-boundary', 'next-check']);\nconst allowedEvidence = new Set([\n  'sample.headers.cache-control',\n  'sample.response.api-version',\n]);\nconst forbiddenWords = ['score', 'ranking', 'hiring', 'candidate', 'person'];\n\nfunction validateHandOff(record) {\n  const errors = [];\n  const text = typeof record?.text === 'string' ? record.text : '';\n\n  if (record?.kind !== 'hand-off') errors.push('kind');\n  if (!record?.sampleId) errors.push('sampleId');\n  if (!allowedCriteria.has(record?.criterionId)) errors.push('criterionId');\n  if (!allowedEvidence.has(record?.evidenceRef)) errors.push('evidenceRef');\n  if (!text.startsWith('request: ')) errors.push('request-prefix');\n  if (forbiddenWords.some((word) => text.toLowerCase().includes(word))) {\n    errors.push('personal-or-decision-outcome');\n  }\n\n  return errors.length === 0\n    ? { accepted: true, action: 'request-missing-evidence', errors: [] }\n    : { accepted: false, action: 'stop-and-repair-boundary', errors };\n}\n\nconst cases = [\n  {\n    name: 'valid request',\n    record: {\n      sampleId: 'api-cache-fixed-v1',\n      criterionId: 'evidence-boundary',\n      evidenceRef: 'sample.headers.cache-control',\n      kind: 'hand-off',\n      text: 'request: origin cache rule',\n    },\n    accepted: true,\n  },\n  {\n    name: 'unknown evidence',\n    record: {\n      sampleId: 'api-cache-fixed-v1',\n      criterionId: 'evidence-boundary',\n      evidenceRef: 'sample.guess.about-author',\n      kind: 'hand-off',\n      text: 'request: origin cache rule',\n    },\n    accepted: false,\n  },\n  {\n    name: 'personal outcome',\n    record: {\n      sampleId: 'api-cache-fixed-v1',\n      criterionId: 'next-check',\n      evidenceRef: 'sample.response.api-version',\n      kind: 'hand-off',\n      text: 'request: candidate ranking',\n    },\n    accepted: false,\n  },\n];\n\nconst results = cases.map(({ name, record, accepted }) => {\n  const result = validateHandOff(record);\n  return { name, expected: accepted, actual: result.accepted, result };\n});\n\nconsole.log(JSON.stringify(results, null, 2));\nif (results.some(({ expected, actual }) => expected !== actual)) {\n  process.exitCode = 1;\n}
\n

Проверка запуска:

\n
node --check calibration.mjs\nnode calibration.mjs
\n

Ожидаемый результат — первая запись принята с действием request-missing-evidence, две следующие отклонены с действием stop-and-repair-boundary. Ключевой отрицательный тест — candidate ranking: даже существующие идентификаторы не разрешают передать личный outcome. Если убрать проверку evidenceRef или список запрещённых слов, тесты перестанут охранять заявленную границу.

\n

В реальном сервисе справочники должны быть частью версии конкретной пробы, а не глобальными константами. Полезно также валидировать типы, длину текста, владельца артефакта и права доступа. Эти поля намеренно не реализованы здесь: добавление их без контракта проекта создало бы видимость универсального решения.

\n

Как найти первую точку расхождения

\n

Сравнивайте записи слева направо, от менее интерпретируемого к более интерпретируемому. Сначала убедитесь, что совпали sampleId и версия рубрики. Затем откройте каждую ссылку evidence и выпишите наблюдение дословно. После этого сравните unknown. Только при совпадающих фактах переходите к interpretation и hand-off.

\n
Первая разницаВероятная причинаПроверкаИсправление
sampleId или версия rubricРецензенты получили разные входыСверить идентификаторы и хеши фикстурыПовторить прогон на одном входе
ObservationВ тексте неоднозначный фрагмент или его по-разному прочиталиОткрыть одну и ту же ссылку и записать видимые символыУточнить sample или инструкцию чтения
UnknownКритерий скрывает недостающее условиеПроверить, названо ли отсутствие явноДобавить поле unknown и не заполнять его догадкой
InterpretationОдинаковые факты связывают с разными гипотезамиНайти предложение, где появляется причинный выводЗаписать гипотезу и один тест, не менять сразу всю rubric
Hand-offГраница действия не описанаПрогнать положительный и запрещённый текст через валидаторОставить только запрос evidence или остановку
\n

Один цикл должен менять один артефакт. Если одновременно переписать sample, rubric и инструкцию рецензента, следующий результат не объяснит, что устранило расхождение. Старую версию сохраняйте как обезличенную учебную фикстуру с понятным статусом, а не как запись о конкретном собеседнике.

\n

Короткая процедура калибровочной сессии

\n
  1. Опишите границу. Запишите, что проба проверяет: например, поиск отсутствующего условия в API-ответе. Отдельно запишите, чего она не измеряет.
  2. Заморозьте вход. Дайте всем один sampleId, одну версию текста и одну версию рубрики. Зафиксируйте отсутствие нужных фактов.
  3. Соберите независимые записи. До встречи каждый рецензент указывает observation, unknown и interpretation со ссылками на поля или строки.
  4. Сравните факты. Проверьте идентификаторы, ссылки и буквальное содержимое evidence. Не обсуждайте личные качества и общий «уровень» ответа.
  5. Назовите первую развилку. Отметьте, на каком поле записи впервые расходятся. Это рабочая единица исправления.
  6. Выберите один следующий тест. Если не хватает правила origin, запросите правило origin. Не подменяйте этот запрос изменением конфигурации или выводом о человеке.
  7. Измените один артефакт. Исправьте только sample, критерий или инструкцию. Свяжите новую версию с причиной изменения.
  8. Повторите прогон. Используйте тот же идентификатор и проверьте обе ветки: разрешённый запрос evidence и запрещённый личный outcome.
\n

Когда метод не подходит

\n

Фиксированная проба проверяет согласованность чтения и качество границы evidence. Она не измеряет производительность инженера, будущую работу в команде, результат реального проекта или вероятность успеха на должности. Отсутствие расхождения на одном sample не доказывает, что rubric валидна для всех задач.

\n

Для реального отбора нужны отдельные решения владельцев процесса: анализ работы, набор релевантных критериев, одинаковые условия, правила хранения записей и проверка применимых требований. OPM описывает structured interview как метод с заранее заданными одинаковыми вопросами и общей шкалой; это полезная опорная идея, но не готовый регламент для частной команды и не совет по законодательству конкретной страны.

\n

Юридическая применимость особенно зависит от юрисдикции и способа использования результата. Страница EEOC перечисляет федеральные правила США, включая 29 CFR 1607 — Uniform Guidelines on Employee Selection Procedures. Российская, европейская или другая процедура требует собственной правовой проверки. Учебный валидатор из этой статьи не заменяет юриста, HR-политику, оценку доступности или требования к защите персональных данных.

\n

Метод также не исправляет плохой вопрос. Если ответ можно получить случайным угадыванием, если отсутствуют ключевые входы или если критерий требует «понять намерение автора», рецензенты будут расходиться по содержательной причине. Сначала уменьшите утверждение до наблюдаемого действия, затем добавляйте сложность и новые пробы.

\n

Критерий готовности

\n

Калибровка готова к повторному использованию, если независимая запись отвечает на четыре вопроса без устного пояснения: какой вход читали; какой факт открыт по ссылке; что осталось неизвестным; почему следующий hand-off принят или остановлен. Для каждого расхождения назван один изменённый артефакт. Положительный запуск возвращает только запрос недостающего evidence. Отрицательные запуски отклоняют несуществующую ссылку, неизвестный критерий и попытку создать личное решение.

\n

Храните рядом версию sample, версию rubric и результаты отрицательных тестов. Тогда следующий рецензент сможет воспроизвести не только успешный путь, но и причину остановки. Это делает калибровку техническим процессом с проверяемыми границами, а не голосованием за самое убедительное впечатление.

\n

Проверяемые источники

\n" }