diff --git a/editorial/agent-rewrites/053.json b/editorial/agent-rewrites/053.json index dc7d2c0..c877e99 100644 --- a/editorial/agent-rewrites/053.json +++ b/editorial/agent-rewrites/053.json @@ -1,7 +1,7 @@ { "index": 53, "slug": "editorial-2026-07-mechanism-migration-playbook", - "title": "Миграция без ложного rollback: четыре проверяемых границы перехода", - "excerpt": "Без инвентаря, сопоставимого трафика, обратимого состояния данных и именованного триггера rollback статус «готово» ничего не доказывает. Разбираем механизм, отрицательные ветки и критерий готовности.", - "contentHtml": "

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

\n

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

\n

Тезис: переход состоит из независимых ворот

\n

Безопасный переход — это не кнопка и не линейный список задач. Это последовательность ворот. Сначала система должна иметь полный для выбранного случая инвентарь. Затем она должна сравнивать control и candidate на одной границе. После этого нужно отдельно описать состояние данных и способ его восстановления. В конце нужен именованный триггер rollback, владелец решения и оба состояния возврата.

\n

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

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

Механизм: сначала объект, потом движение

\n

Начните с одного маршрута и одного типа записи. Для маршрута назовите owner, reader, writer и dependency. Для записи назовите source и target. Такая связка отвечает на вопрос: что именно меняет переход и кто видит результат. Не выводите эти связи из похожего имени файла, URL или сервиса. Если связь неизвестна, верните остановку. Предположение «скорее всего, это тот же объект» уже меняет смысл проверки.

\n

Инвентарь не обязан охватить всю платформу. Он обязан быть замкнутым для выбранного учебного или реального среза. Если карточка описывает только чтение, не называйте её миграцией записи. Если dependency не указана, нельзя оценивать трафик или восстановление: неизвестно, к какой части данных относится наблюдение.

\n

Вторые ворота проверяют трафик. Control и candidate должны иметь одну route boundary. Доли 90/10 сами по себе ничего не значат. Если control обслуживает другой путь, это не сравнение. Если control не назван, candidate проверяет себя сам. Observation window тоже должно иметь имя. В реальной среде оно включает период, источник метрик и правила расчёта. В учебном объекте достаточно зафиксировать границу, но нельзя выдавать её за реальные измерения.

\n

Третьи ворота относятся к данным. Флаг migrated: true не описывает обратный путь. Нужны source, target, reconciliation и restore state. Копия, прошедшая проверку количества строк, ещё не означает, что старую write-семантику можно восстановить. Это особенно важно при расширении схемы, смене идентификатора или переносе владельца записи.

\n

Четвёртые ворота делают rollback решаемым. Назовите trigger, условие, decision owner, traffic return и data return. Слово «аномалия» слишком широко. Оно не говорит, какой сигнал остановит переход. «Ошибки выросли» тоже недостаточно, если не указаны окно, источник и правило сравнения. Именованный trigger не запускает откат сам. Он делает вопрос воспроизводимым для того, кто принимает решение.

\n

Конкретный пример: fail-closed проверка

\n

Ниже — учебный JavaScript-пример. Он проверяет только фиксированный объект в памяти. Он не читает балансировщик, базу данных, метрики или Kubernetes API. Его задача — показать отрицательную ветку: candidate существует, но контрольная граница не задана.

\n
const record = {\n  inventory: {\n    route: 'ledger-read',\n    owner: 'payments-team',\n    dependency: 'ledger-entry'\n  },\n  traffic: {\n    candidate: { routes: ['ledger-read'], share: 10 },\n    control: { routes: [], share: 90 },\n    observationWindow: 'window-v1'\n  }\n};\n\nfunction evaluate(input) {\n  if (!input.inventory.route || !input.inventory.owner || !input.inventory.dependency) {\n    return 'stop-missing-inventory';\n  }\n\n  const candidate = input.traffic.candidate.routes;\n  const control = input.traffic.control.routes;\n  if (!candidate.length || !control.length || candidate.join() !== control.join()) {\n    return 'stop-unbounded-traffic-slice';\n  }\n\n  return 'continue-to-data-and-rollback-gates';\n}\n\nconsole.log(evaluate(record));\n// stop-unbounded-traffic-slice
\n

Результат не говорит, что новый маршрут опасен. Он говорит более узко: текущая запись не задаёт объект сравнения. Исправление тоже узкое — назвать control с той же границей и повторить проверку. Не следует заменять этот вывод фразой «проверить балансировщик»: код не имеет такого доступа и не может подтвердить действие в окружении.

\n

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

\n
Диагностика перехода по первой наблюдаемой причине
СимптомПричинаПроверкаДействие
Rollback вернул старый маршрут, но записи расходятсяТрафик и data state считались одним rollbackНазвать отдельно traffic return и data restoreОстановить переход до описания recovery state
Есть candidate 10% и control 90%У наборов разные route boundariesСравнить route set, owner и окно наблюденияНе считать доли сравнением
Копия завершилась успешноCopy приняли за обратимое состояниеПроверить source, target, reconciliation и restoreВернуть stop-irreversible-data-state
В документе написано «откат при проблеме»Нет trigger, условия и владельца решенияНайти named condition и два return stateНе расширять слово rollback; дописать границы
Сервис не назван в инвентареСвязь вывели по имени или предположениюПроверить owner, reader, writer и dependencyОстановить review на stop-missing-inventory
\n

Отрицательный путь важнее зелёной ветки

\n

Представьте три неполных карточки. В первой нет dependency. Правильный результат — stop-missing-inventory; обсуждать проценты трафика рано. Во второй control и candidate указывают разные маршруты. Результат — stop-unbounded-traffic-slice; числа 90 и 10 не становятся доказательством. В третьей есть source, target и copy, но нет restore state. Результат — stop-irreversible-data-state; возврат маршрута не объявляют полным rollback.

\n

Есть и четвёртая ошибка: trigger назван, но не задано условие. «Rollback по решению владельца» не отвечает на вопрос, какое наблюдение открывает решение. В таком случае возвращается stop-unnamed-rollback-trigger. Система не должна угадывать порог, подставлять последний dashboard или считать любой timeout достаточным. У разных переходов разные сигналы и разные допустимые последствия.

\n

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

\n

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

\n
  1. Выберите один переход и зафиксируйте его границу: маршрут, тип записи и owner.
  2. Свяжите маршрут с reader, writer и dependency. Не заполняйте пропуск догадкой.
  3. Опишите control и candidate с одинаковым route set. Назовите observation window и поля, которые в нём будут проверяться.
  4. Опишите data transition: source, target, reconciliation и restore state. Не используйте флаг «миграция завершена» как замену обратному пути.
  5. Назовите trigger, условие, decision owner, traffic return и data return. Разведите эти сущности в отдельных полях или строках.
  6. Проверьте по очереди положительный случай и контрпримеры для каждого ворот. Сохраните первый stop reason и следующее действие.
  7. Ограничьте положительный результат статусом hand-off на следующий review. Не превращайте его в разрешение на cutover.
\n

Что подтверждают официальные механизмы, а чего не подтверждают

\n

У слова rollback нет общего смысла для всех слоёв. В документации Kubernetes откат Deployment относится к его Pod template. Это полезная граница: возврат версии workload не означает возврат записей в хранилище и не отменяет побочные эффекты приложения. Поэтому в карточке перехода traffic return и data return должны быть отдельными полями.

\n

У базы данных граница может быть другой. PostgreSQL описывает ROLLBACK как отмену изменений текущей транзакции. Это не равно восстановлению данных, уже записанных в другой транзакции, внешней очереди или стороннем сервисе. Нельзя перенести семантику одной транзакции на весь процесс миграции. Сначала назовите объект и границу действия.

\n

Ограничения

\n

Эта модель не измеряет задержку, error rate, нагрузку, стоимость простоя или долю пользователей. Она не проверяет корректность выбранного owner. Она не знает, что произойдёт при конкурирующих записях, повторной доставке сообщения или частичной недоступности хранилища. Именованный trigger тоже может быть плохим. Механизм лишь не даёт скрыть его отсутствие.

\n

Учебный код не создаёт traffic slice, не запускает SQL, не меняет Deployment и не выполняет восстановление. Его можно использовать для проверки формы карточки и отрицательных веток. Перед реальным переходом нужны инвентарь конкретной системы, rehearsal, наблюдение, права на действие и отдельно описанная процедура восстановления. Ни один положительный результат этой статьи не заменяет их.

\n

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

\n

Механизм готов к следующему review, если независимый читатель получает одинаковый результат из одной карточки: выбранный объект назван; owner, reader, writer и dependency связаны; control и candidate имеют общую границу; observation window указан; data state содержит reconciliation и restore; rollback имеет trigger, условие, владельца и два return state; каждый неполный вариант выдаёт именованный stop. Положительный результат остаётся hand-off. В нём нет утверждения о выполненном rollout, исправленных данных или достигнутом production-эффекте.

\n

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

" + "title": "Миграция без ложного rollback: четыре проверяемые границы перехода", + "excerpt": "Rollback трафика не возвращает данные автоматически. Разбираем inventory, сопоставимый control/candidate, состояние данных и именованный триггер, чтобы отличить проверяемую готовность от зелёного статуса.", + "contentHtml": "

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

\n

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

\n

Четыре границы вместо одного статуса

\n

Переход начинается с inventory — замкнутого описания выбранного объекта. Для маршрута нужно назвать owner, reader, writer и dependency. Для записи — source и target. Это не попытка описать всю платформу. Это минимальная граница, внутри которой результат проверки имеет смысл.

\n

Вторая граница — traffic. Control и candidate должны обслуживать один route boundary и иметь сопоставимое observation window. Доли 90/10 не превращают два разных маршрута в эксперимент. Если control не назван, candidate сравнивает себя с неизвестностью.

\n

Третья граница — data state. У неё есть минимум source, target, reconciliation и restore. Копирование строк или выставленный флаг migrated: true не доказывают, что старую write-семантику можно вернуть. Особенно опасны смена идентификатора, преобразование значения и запись в несколько систем.

\n

Четвёртая граница — rollback decision. Нужны trigger, условие, decision owner, traffic return и data return. Слово «аномалия» слишком широко: оно не говорит, какой сигнал остановит переход. Именованный trigger тоже ничего не откатывает сам. Он делает решение воспроизводимым для конкретного владельца.

\n
\"Матрица
Возврат маршрута, восстановление данных и право принять решение — разные действия. Иллюстрация показывает их как независимые проверки.
\n

Что именно считается доказательством

\n

У каждой границы должен быть наблюдаемый вход и ограниченный вывод. Inventory даёт имена связям, но не подтверждает корректность архитектуры. Traffic описывает сравнение, но не гарантирует хороший результат. Data state показывает возможность reconciliation и restore, но не заменяет rehearsal. Rollback record фиксирует решение, но не исполняет его.

\n

Полезно различать два результата. stop означает, что не хватает конкретного факта и следующий шаг известен. ready-for-review означает только полноту записи для следующего инженерного просмотра. Это не разрешение на cutover, не доказательство успешного production-запуска и не заявление о восстановленных данных.

\n

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

\n

Почему Kubernetes и PostgreSQL откатывают разное

\n

Документация Kubernetes описывает rollback Deployment как возврат к предыдущей ревизии его конфигурации. В этой модели возвращается Pod template: например, образ контейнера и связанные поля шаблона. Deployment controller создаёт или масштабирует ReplicaSet, а история ревизий хранится в ReplicaSet. Это граница workload, а не обещание вернуть состояние приложения.

\n

Из этого следует практическое правило: kubectl rollout undo может быть корректным действием для маршрута или набора Pod, но он не отменяет записи, уже зафиксированные приложением, и не отзывает побочный вызов во внешний сервис. Если старые ReplicaSet удалены из-за ограничения истории, выбранная ревизия может стать недоступной для такого возврата. Это ещё одна причина называть версию и её сохранность до начала перехода.

\n

В PostgreSQL граница уже: ROLLBACK отменяет текущую транзакцию и отбрасывает изменения, сделанные этой транзакцией. Команда не распространяет это свойство на другую транзакцию, очередь, HTTP-вызов или отдельный процесс миграции. Поэтому транзакционный rollback и восстановление данных после частичного перехода нельзя записывать одним полем.

\n

Воспроизводимый record и fail-closed проверка

\n

Ниже — учебный JavaScript-пример. Он работает только с объектом в памяти и проверяет полноту записи. В нём нет доступа к балансировщику, Kubernetes API, базе или метрикам. Все значения проектные: их нужно заменить фактами конкретной системы перед применением.

\n
const migration = {\n  inventory: {\n    route: 'ledger-read',\n    owner: 'payments-team',\n    reader: 'ledger-api-v2',\n    writer: 'ledger-writer',\n    dependency: 'ledger-entry'\n  },\n  traffic: {\n    boundary: 'ledger-read',\n    control: { boundary: 'ledger-read', share: 90 },\n    candidate: { boundary: 'ledger-read', share: 10 },\n    observationWindow: '2026-07-15T10:00Z/2026-07-15T10:15Z'\n  },\n  data: {\n    source: 'ledger-v1',\n    target: 'ledger-v2',\n    reconciliation: 'count+checksum+sample',\n    restore: 'restore-snapshot-ledger-v1'\n  },\n  rollback: {\n    trigger: 'candidate_error_rate',\n    condition: '5m rate exceeds control by 1 percentage point',\n    decisionOwner: 'payments-oncall',\n    trafficReturn: 'route to ledger-api-v1',\n    dataReturn: 'restore verified snapshot, then reconcile'\n  }\n};\n\nfunction evaluate(input) {\n  const inventory = input.inventory;\n  if (Object.values(inventory).some((value) => !value)) {\n    return 'stop-missing-inventory';\n  }\n\n  const { traffic, data, rollback } = input;\n  const sameBoundary =\n    traffic.boundary === traffic.control.boundary &&\n    traffic.boundary === traffic.candidate.boundary;\n  if (!sameBoundary || !traffic.observationWindow) {\n    return 'stop-unbounded-traffic-slice';\n  }\n\n  if (Object.values(data).some((value) => !value)) {\n    return 'stop-irreversible-data-state';\n  }\n\n  if (Object.values(rollback).some((value) => !value)) {\n    return 'stop-unnamed-rollback-trigger';\n  }\n\n  return 'ready-for-review';\n}\n\nconsole.log(evaluate(migration));\n// ready-for-review
\n

Проверка намеренно останавливается на первой обнаруженной границе. Это не скрывает остальные дефекты: она задаёт ближайшую работу, которую можно выполнить без догадки. Если удалить traffic.control.boundary, результат станет stop-unbounded-traffic-slice. Если оставить traffic полным, но убрать data.restore, вернётся stop-irreversible-data-state. Такой контрпример полезнее сообщения «миграция не готова»: он показывает, какое поле и почему нужно восстановить.

\n

Диагностика по наблюдаемому симптому

\n
Первая проверка после обнаружения симптома
СимптомГипотезаПроверкаДействие
Старый маршрут отвечает, а записи расходятсяTraffic return приняли за полный rollbackСверить data source, target, reconciliation и restoreОстановить переход до описания восстановления данных
Есть candidate 10% и control 90%Числа скрывают разные границы сравненияСравнить boundary, route set и observation windowНе считать доли доказательством
Копия строк завершилась успешноCopy приняли за обратимое состояниеПроверить контрольную сумму, выборку и restore rehearsalОставить stop до подтверждения обратного пути
В документе написано «откат при проблеме»Нет условия и владельца решенияНайти trigger, порог, окно и decision ownerНазвать правило, а не расширять слово rollback
Сервис выведен по имени файлаСвязь между компонентами предположилиПодтвердить owner, reader, writer и dependencyВернуть запись на inventory-проверку
\n

Таблица не заменяет метрики. Она определяет, к какой метрике или записи нужно обратиться первой. Например, рост ошибок candidate сравнивают с control в том же окне и на той же границе. Одного абсолютного числа без baseline недостаточно: оно может отражать общий сбой зависимости, а не эффект новой версии.

\n

Как провести проверку перед переключением

\n
  1. Выберите один маршрут и один тип записи. Зафиксируйте owner и границу действия, не смешивая чтение, запись и восстановление в одном названии.
  2. Подтвердите inventory по конфигурации, коду или наблюдаемому вызову. Если reader, writer или dependency неизвестны, остановитесь на этой ветке.
  3. Задайте control и candidate с одинаковым route boundary. Зафиксируйте observation window, baseline и поля сравнения: error rate, latency, корректность ответа или другой сигнал.
  4. Опишите data transition: source, target, reconciliation и restore. Отдельно проведите rehearsal восстановления на безопасной копии и запишите, что именно проверено.
  5. Сформулируйте trigger как измеримое условие. Укажите окно, порог, decision owner, traffic return и data return, включая порядок действий.
  6. Проверьте положительный record и минимум по одному контрпримеру на каждую границу. Ожидаемый результат каждой отрицательной ветки должен быть именован.
  7. Передайте запись на следующий review с пометкой о границе доказательства. Не называйте hand-off выполненным rollout и не меняйте среду из этого чек-листа.
\n

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

\n

Модель подходит для миграции маршрута, схемы, владельца данных или версии workload, когда можно явно назвать объект и его границы. Она не выбирает порог error rate, не доказывает, что 10% трафика статистически достаточны, и не отвечает за согласованность между несколькими хранилищами. Эти решения зависят от нагрузки, критичности операции, требований к задержке и допустимого риска.

\n

Учебный код не создаёт traffic slice, не выполняет SQL, не вызывает kubectl и не восстанавливает snapshot. Он проверяет только форму записи и порядок fail-closed веток. Для реального перехода нужны права на действие, резервная копия с подтверждённым восстановлением, наблюдение за зависимостями и план для сообщений или побочных вызовов, которые нельзя отменить транзакцией.

\n

Есть и случаи, где rollback невозможен по определению: отправлено письмо, списана внешняя комиссия, опубликовано событие в системе без компенсационной операции. Тогда data return должен описывать не «вернуть назад», а compensating action, идемпотентность и контроль остаточного эффекта. Если компенсации нет, переход нельзя объявлять обратимым; нужно уменьшать blast radius до начала записи.

\n

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

\n

Запись готова к инженерному review, когда независимый читатель находит в ней один объект перехода, подтверждённые связи inventory, общую traffic boundary, окно наблюдения, source и target данных, способ reconciliation, проверенный restore, измеримый trigger, decision owner и оба return state. Положительная ветка выдаёт только ready-for-review. Она не утверждает, что переключение состоялось, данные исправны или production-эффект достигнут.

\n

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

" } diff --git a/editorial/agent-rewrites/054.json b/editorial/agent-rewrites/054.json index d8b9448..1cd2a80 100644 --- a/editorial/agent-rewrites/054.json +++ b/editorial/agent-rewrites/054.json @@ -3,5 +3,5 @@ "slug": "editorial-2026-07-practice-migration-playbook", "title": "Миграция без прыжка: как сохранить данные и управлять откатом", "excerpt": "Пошаговая схема миграции с инвентарём, совместимыми версиями, контрольным срезом трафика и отдельным планом возврата данных.", - "contentHtml": "

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

\n

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

\n

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

\n

Механизм: сначала совместимость, потом переключение

\n

Рассмотрим учебный пример. Сервис заказов хранит поле status, а новая версия хочет использовать state. Нельзя сразу удалить старое поле. Сначала новая схема принимает оба имени, затем приложение пишет оба значения, потом команда сверяет записи и переводит чтение на новое поле. Только после этого старый контракт можно убрать.

\n

Такой порядок разделяет четыре разных изменения. Схема должна принять новый формат. Писатель должен создать согласованные значения. Читатель должен уметь сравнить старое и новое представление. Маршрутизатор должен направить ограниченный поток на новый путь. Если один шаг смешать с другим, откат приложения не отменит изменение данных.

\n
\"Схема
Переход проходит через четыре границы. Красная ветка означает остановку, если не названа зависимость, состояние данных, контрольный маршрут или условие возврата.
\n

Инвентарь показывает границу риска

\n

Начните с одного маршрута, а не со всей системы. Запишите его владельца, читателя, писателя, запись и внешние зависимости. Для примера это GET /orders/:id, таблица orders, обработчик записи и индекс, которым пользуется отчёт.

\n

Связи важнее списка файлов. Если известен маршрут, но неизвестен писатель, нельзя оценить совместимость записи. Если известен писатель, но нет читателя отчёта, нельзя определить, где появится расхождение. Пустое звено — это не мелкая недостача документа. Это причина остановить переход до проверки.

\n

Состояние данных не равно копии

\n

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

\n

Для учебного сценария достаточно такой модели:

\n
const migration = {\n  route: 'orders-read-v1',\n  owner: 'orders-team',\n  source: 'orders.status',\n  target: 'orders.state',\n  reconciliation: 'same-keys-and-normalized-values',\n  writeRecovery: 'resume-source-writes',\n  traffic: { control: 'orders-read-v1', candidate: 'orders-read-v2' },\n  rollback: {\n    trigger: 'contract-mismatch-in-observation-window',\n    traffic: 'restore-control-route',\n    data: 'resume-source-writes'\n  }\n};
\n

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

\n
Диагностика перехода
СимптомПричинаПроверкаДействие
Новый читатель видит пустое полеКопия создана, но запись не синхронизированаСравнить ключи и нормализованные значения на одной выборкеОстановить срез и вернуть чтение на control
Старый и новый отчёты расходятсяРазные правила преобразованияСравнить результат одного заказа в обоих представленияхИсправить преобразование до расширения среза
После rollback появляются новые расхожденияВозврат маршрута не вернул write stateПроверить, какой писатель принимал записи в окнеВозобновить источник или применить обратное преобразование
Нельзя выбрать момент остановкиНе назван trigger и владелец решенияНайти условие, окно наблюдения и ответственногоНе считать миграцию готовой
Кандидат работает лучше, но сравнение спорноеНет control с тем же маршрутомСверить route boundary, запросы и окноСоздать сопоставимую контрольную сторону
\n

Контрольный срез должен иметь две стороны

\n

Число «10% трафика» само по себе ничего не доказывает. Нужна контрольная сторона с тем же типом запроса, сопоставимым окном и одинаковыми правилами подсчёта ошибок. В учебной модели orders-read-v1 — control, а orders-read-v2 — candidate. Это имена границ, а не рекомендация направлять ровно десять процентов реального трафика.

\n

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

\n

Rollback состоит из трёх разных возвратов

\n

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

\n

Укажите условие остановки до начала среза. Например: «в окне наблюдения появился mismatch контракта для нормализованного значения». Затем укажите, кто принимает решение, куда возвращается чтение и какой писатель принимает новые данные после возврата. Если запись уже прошла только через новую схему, одного переключения маршрута недостаточно.

\n

Официальная документация Kubernetes прямо ограничивает смысл rollback Deployment: при возврате ревизии восстанавливается Pod template. Это полезное различие. Возврат контейнера не отменяет SQL-изменения, сообщения в очереди или внешний API-контракт. Такие состояния нужно проектировать отдельно.

\n

Учебная проверка карточки

\n

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

\n
function checkMigration(card) {\n  if (!card.route || !card.owner || !card.source || !card.target) {\n    return { status: 'stop-missing-inventory' };\n  }\n  if (!card.reconciliation || !card.writeRecovery) {\n    return { status: 'stop-irreversible-data-state' };\n  }\n  if (card.traffic?.control === card.traffic?.candidate) {\n    return { status: 'stop-missing-control-boundary' };\n  }\n  if (!card.rollback?.trigger || !card.rollback?.traffic || !card.rollback?.data) {\n    return { status: 'stop-unnamed-rollback' };\n  }\n  return { status: 'ready-for-environment-specific-review' };\n}\n\nconsole.log(checkMigration(migration));\n// { status: 'ready-for-environment-specific-review' }
\n

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

\n

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

\n
  1. Выбрать один маршрут и записать его владельца, читателя, писателя, запись и зависимости.
  2. Добавить новый формат без удаления старого и проверить, что обе версии могут читать данные.
  3. Назвать источник, назначение, правило сверки и write state, который возвращается при отказе.
  4. Сформировать control и candidate на одной границе маршрута и определить окно наблюдения.
  5. Заранее записать trigger, владельца решения, возврат маршрута и восстановление записи.
  6. Запустить учебную или тестовую проверку с отрицательными примерами: пустой писатель, несовпадающие ключи и rollback без data state.
  7. Расширять срез только после проверки фактических данных и разрешения, принятого владельцем сервиса.
  8. Удалять старый контракт последним, когда читатели и писатели больше от него не зависят.
\n

Ограничения

\n

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

\n

Схема также не заменяет rehearsal. Учебный объект проверяет полноту описания, но не проверяет реальную выборку. Для production нужны контрольные запросы, журнал изменений, лимит времени, доступ к процедуре восстановления и ответственный, который может остановить переход. Если хотя бы один из этих элементов не проверен, критерий готовности не выполнен.

\n

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

\n

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

\n

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

" + "contentHtml": "

После переключения на новую версию часть заказов читает новое поле, а часть продолжает писать старое. HTTP-ответы остаются успешными, поэтому сбой обнаруживается позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не отменяет уже записанные значения. Команда видит зелёный deploy, но не может быстро ответить, что именно возвращать.

\n

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

\n

Ниже — рабочая модель для изменения контракта заказа с status на state. Это не инструкция для конкретной базы или балансировщика. Её цель — заставить миграцию отвечать на четыре вопроса: какой участок меняется, как доказать совместимость, где остановить поток и как восстановить запись после отказа.

\n

Миграция — это последовательность состояний

\n

Безопасный переход состоит не из одного cutover, а из состояний, которые можно наблюдать и покинуть. Сначала старый контракт остаётся рабочим. Затем новая схема принимает оба представления. После этого писатель создаёт согласованные значения, а сверка проверяет уже существующие записи. Только потом новый читатель получает ограниченный поток.

\n

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

\n
\"Четыре
Каждый переход имеет проверку и стоп-ветку. Пока не названы владелец, состояние данных, контрольная сторона и условие возврата, поток не расширяется.
\n

Инвентарь ограничивает область риска

\n

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

\n

Полезный инвентарь отвечает на вопрос «кто ещё может записать старую форму?». Один забытый batch-job способен продолжать отправлять status после переключения чтения на state. Один отчёт с собственным SQL может видеть другую картину, даже если основной API выглядит исправным. Неизвестный писатель — самостоятельный стоп-сигнал, а не поле для предположения.

\n

Зафиксируйте границу операции. Например, candidate обслуживает только чтение заказа через один API-маршрут, а фоновая выгрузка остаётся на control. Тогда результат среза относится к конкретному маршруту и набору запросов, а не ко всей платформе. Если границы различаются, их сравнивают отдельно.

\n

Совместимость начинается с формата записи

\n

Для изменения имени поля примените expand/contract-последовательность. На этапе expand добавьте state, не удаляя status, и разрешите чтение обеих форм. Затем выберите источник истины: например, новое значение вычисляется из старого, пока двойная запись не станет проверяемой. При каждой записи сохраняйте правило преобразования, а не только итоговое значение.

\n

На этапе двойной записи обработчик должен быть идемпотентным: повтор одной операции не создаёт новую сущность и не меняет результат непредсказуемо. Это требование зависит от ключа и бизнес-операции; универсальная функция «перезаписать всё» его не обеспечивает. Отдельно проверьте null, неизвестное значение, смену регистра, часовой пояс и округление, если они участвуют в преобразовании.

\n

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

\n

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

\n

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

\n

Учебный SQL ниже показывает форму проверки, а не готовую команду для вашей схемы. Он отдельно считает отсутствующий ключ и сравнивает значения null-safe. На реальном стенде добавьте фильтр по согласованному срезу, лимит нагрузки, обработку удаления и защиту от чтения незавершённой записи.

\n
WITH compared AS (\n  SELECT\n    old.id AS old_id,\n    new.id AS new_id,\n    old.status AS old_value,\n    new.state AS new_value\n  FROM orders_old AS old\n  FULL OUTER JOIN orders_new AS new ON new.id = old.id\n)\nSELECT\n  count(*) AS checked,\n  count(*) FILTER (WHERE old_id IS NULL) AS missing_old,\n  count(*) FILTER (WHERE new_id IS NULL) AS missing_new,\n  count(*) FILTER (\n    WHERE old_id IS NOT NULL\n      AND new_id IS NOT NULL\n      AND old_value IS DISTINCT FROM new_value\n  ) AS mismatched\nFROM compared;
\n

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

\n

Контрольный срез сравнивает одинаковые условия

\n

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

\n

До включения candidate запишите базовую линию control. В течение окна фиксируйте объём запросов, долю ошибок, p95 или другой согласованный показатель задержки, mismatches и обращения к fallback. Порог не следует выдумывать в статье: его определяет SLO и риск конкретной операции. Важно, чтобы команда заранее знала, какое событие останавливает расширение.

\n
Симптомы и решения на границах миграции
СимптомВероятная границаПроверкаРешение
Новый читатель получает пустое полеСхема или двойная записьСопоставить ключи и момент первой записи в обеих формахОстановить candidate; восстановить запись и повторить сверку
Старый и новый отчёт расходятсяПреобразование или собственный потребительСравнить одну запись по исходному ключу и правилам нормализацииИсправить контракт отчёта до расширения потока
Ошибок API нет, но растут mismatchesСемантика данныхРазложить расхождения на null, ключ, значение и время записиНе считать HTTP 2xx разрешением на cutover
После возврата маршрута появляются новые расхожденияWrite stateПроверить, какой писатель принимал данные в окнеВернуть совместимый писатель или применить проверенное преобразование
Нельзя определить момент остановкиРешение и наблюдаемостьНайти порог, окно, owner и команду остановкиОставить control и не расширять candidate
\n

Rollback нужно разделить на три действия

\n

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

\n

Документация Kubernetes уточняет границу rollback Deployment: ревизия создаётся при изменении Pod template, а возврат к предыдущей ревизии откатывает именно эту часть Deployment. Это возвращает образ, параметры и другие элементы шаблона Pod, но не SQL-транзакции, сообщения очереди, записи во внешнем сервисе или уже опубликованный контракт. Их состояние описывается отдельными шагами.

\n

Если data state необратим, откат должен быть не «вернуть старое», а заранее проверенный способ продолжить работу: dual-read, обратное преобразование, остановка записи или восстановление из согласованной копии. Выбор зависит от потерь и бизнес-операции. До среза выполните его на тестовом наборе и зафиксируйте, как обнаруживаются частичные результаты.

\n

Карточка перехода делает решение воспроизводимым

\n

Соберите перед запуском одну карточку. В ней должны быть route boundary, owner, source, target, версия преобразования, запрос сверки, контрольные метрики, окно, trigger остановки и действия для кода, маршрута и данных. Пример ниже намеренно возвращает stop, если обязательное поле отсутствует. Положительный ответ говорит только о полноте карточки, а не о готовности production-среды.

\n
function evaluateMigration(card) {\n  const required = [\n    'route', 'owner', 'source', 'target',\n    'reconciliation', 'writeRecovery', 'rollbackTrigger'\n  ];\n\n  const missing = required.filter((key) => !card[key]);\n  if (missing.length > 0) {\n    return { status: 'stop', reason: 'missing-fields', missing };\n  }\n\n  if (card.control === card.candidate) {\n    return { status: 'stop', reason: 'same-traffic-boundary' };\n  }\n\n  return { status: 'review', reason: 'card-is-complete' };\n}\n\nconsole.log(evaluateMigration({\n  route: 'orders-read',\n  owner: 'orders-team',\n  source: 'orders.status',\n  target: 'orders.state',\n  reconciliation: 'key-and-normalized-value',\n  writeRecovery: 'resume-compatible-writer',\n  rollbackTrigger: 'mismatch-over-threshold',\n  control: 'orders-read-v1',\n  candidate: 'orders-read-v2'\n}));\n// { status: 'review', reason: 'card-is-complete' }
\n

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

\n

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

\n
  1. Выбрать одну границу операции и записать владельца, читателей, писателей, хранилище, кэш, очередь и внешние зависимости.
  2. Добавить новую форму без удаления старой; проверить чтение старой, новой и смешанной записи.
  3. Зафиксировать источник истины, правило преобразования, идемпотентность и обработку неизвестных значений.
  4. Включить двойную запись на тестовой выборке и повторить операцию, чтобы проверить повторяемость результата.
  5. Сформировать контрольный запрос: ключ, срез, нормализация, допустимые расхождения и лимит нагрузки.
  6. Снять базовую линию control, затем включить небольшой candidate с известным владельцем и окном наблюдения.
  7. При каждом стоп-триггере остановить расширение, записать сигнал и выполнить отдельные действия для кода, маршрута и данных.
  8. Расширять поток только после сверки фактических данных и решения владельца сервиса; не удалять старый контракт в том же изменении.
  9. После окна наблюдения повторить поиск зависимостей, выключить старую запись и только затем удалить совместимость по плану.
\n

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

\n

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

\n

PostgreSQL описывает конкретные ограничения логической репликации: конфликт ограничения может остановить репликацию до ручного разрешения, а отсутствующая строка при UPDATE или DELETE может быть пропущена. Поэтому одинаковое число строк не доказывает, что два состояния равны. Проверьте версию PostgreSQL, replica identity, права, фильтры публикации и статистику конфликтов на целевой конфигурации.

\n

Учебный SQL и JavaScript не подключаются к базе, не управляют трафиком и не подтверждают безопасность реального cutover. Они показывают форму проверки. Перед production-запуском нужны rehearsal на близком объёме данных, резервный план, доступ к остановке потока, журнал результата и человек, который имеет право отменить расширение.

\n

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

\n

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

\n

Если команда способна вернуть только контейнер, но не объясняет судьбу записей, это не rollback миграции. Если есть нулевая сверка, но неизвестно, кто писал в окно, это не доказательство совместимости. Готовность — это не зелёный deploy, а повторяемая проверка с понятным стоп-триггером и обратимым состоянием.

\n

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

" } diff --git a/editorial/agent-rewrites/055.json b/editorial/agent-rewrites/055.json index 8bdeb78..8885590 100644 --- a/editorial/agent-rewrites/055.json +++ b/editorial/agent-rewrites/055.json @@ -1,7 +1,7 @@ { "index": 55, "slug": "editorial-2026-06-field-multi-runtime", - "title": "Когда PHP, JavaScript и D говорят разное: как закрыть границу контракта", - "excerpt": "Разные runtime не становятся одной системой от похожего JSON. Разберём наблюдаемый симптом, явный контракт, отрицательный путь и критерий, который отделяет проверяемую модель от заявления об интеграции.", - "contentHtml": "

Запрос проходит через PHP, затем попадает в JavaScript и заканчивается обработкой на D. Пользователь видит ошибку без понятной причины, а оператор не может связать запись D с исходным запросом. Иногда все три части возвращают похожий JSON, но одна сторона считает ошибку исключением, другая — обычным значением, а третья записывает время в другой шкале. Внешне система работает. Внутри она уже потеряла общий смысл.

\n

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

\n

Тезис: общий смысл живёт на границе

\n

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

\n

В этом материале проверяется модель границы. Учебный пример хранит запись в памяти JavaScript и читает её обычной функцией. Он не запускает PHP, JavaScript как отдельный процесс или D. Он не обращается к сети, диску, часам, телеметрии и сервисам. Поэтому его результат говорит только о внутренней сопоставимости записи. Это ограничение входит в смысл примера.

\n

Механизм: четыре поля, которые нельзя угадывать

\n

Сначала назовите операцию. Строка fixed-order-decision лучше, чем общий «обработчик заказа»: у неё есть конкретная граница. Затем задайте value tag. В примере это order-ready. Число 4200 получает единицу и валюту. Без tag и единицы потребитель может принять копейки за рубли или число лимита за сумму.

\n

Ошибка получает named envelope. В нём явно присутствуют semantics, code и retry. Значение code: null означает отсутствие кода внутри известного envelope. Отсутствующий сам envelope означает другую проблему. Эти два случая нельзя сливать в одну пустую строку.

\n

Время в изолированной модели задаётся ordered fixed logical ticks. Пара 100..108 показывает порядок и интервал из восьми условных шагов. Это не миллисекунды, не latency и не SLA. Если нужен production-замер, он требует отдельного источника времени, политики измерения и проверки среды.

\n
const contract = {\n  schemaVersion: 'fixed-boundary-1',\n  operation: 'fixed-order-decision',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst adapters = ['php', 'javascript', 'd'].map((model) => ({\n  model,\n  contractVersion: 'fixed-boundary-1',\n  valueTag: 'order-ready',\n  errorSemantics: 'named-envelope',\n  timeBasis: 'fixed-logical-ticks',\n  mapping: 'exact'\n}));\n\nfunction check(record) {\n  if (record.schemaVersion !== 'fixed-boundary-1') {\n    return { status: 'stop-incomplete-contract', reason: 'schema-version-missing' };\n  }\n  if (record.adapters.some((item) => item.mapping !== 'exact')) {\n    return { status: 'stop-incomparable-adapter', reason: 'mapping-is-not-exact' };\n  }\n  return { status: 'synthetic-review-hand-off', observedEffect: 'none-observed' };\n}\n\nconsole.log(check({ ...contract, adapters }));
\n

Пример учебный. Он показывает форму проверки, а не совместимость библиотек. В нём имена php, javascript и d — значения поля model. Они не доказывают, что три языка обменялись данными. Положительный результат означает лишь: запись содержит названные поля, версии совпадают, а mapping не скрывает преобразование.

\n

Как распознать ложное совпадение

\n

Одинаковый текст ошибки не задаёт одинаковое действие. PHP может вернуть envelope, JavaScript — выбросить значение, а D — записать код в отдельное поле. Потребитель, который проверяет только сообщение, потеряет режим завершения. Сравнивайте не текст, а семантику: кто владеет ошибкой, можно ли повторить операцию и какие данные сохраняются.

\n

Одинаковое число времени тоже ничего не гарантирует. Один адаптер может передать логические шаги, другой — epoch seconds. Числа совпадут случайно, а вывод окажется ложным. Поэтому basis должна быть полем контракта и каждого адаптера. Пропущенный closed должен закрывать проверку, а не заменяться текущим временем.

\n
Симптомы на границе и проверяемое действие
СимптомПричинаПроверкаДействие
Ответы похожи, но retry ведёт себя по-разномуEnvelope смешан с thrown valueСравнить error semantics и наличие code/retryВыровнять envelope или вернуть stop
Время совпадает только на одном стендеРазные time basisПроверить basis и обе границы интервалаНазвать одну шкалу или прекратить сравнение
Сумма проходит проверку, но меняет порядок величиныTag, unit или currency угадываются по имениПроверить value tag, amountMinor и currencyДобавить явное поле и запретить inference
Адаптер «почти» совпадает с contractСкрытая coercion при mappingПотребовать mapping: exactОписать преобразование явно либо остановить hand-off
Нельзя связать запись D с запросом PHPНет общего correlation fieldПроверить, входит ли идентификатор в contractДобавить поле в новый контракт; не восстанавливать связь по времени
\n
\"Цикл
Схема показывает чтение одной записи в памяти. Стрелки не означают сетевое соединение, запуск runtime или измерение production.
\n

Отрицательный путь важнее happy path

\n

Проверка должна отказываться от удобного вывода. Возьмём запись, где у одного адаптера errorSemantics: 'thrown-value', а у контракта остаётся named-envelope. Такой набор нельзя объявить совместимым. Функция возвращает stop-incomparable-adapter и оставляет владельцу конкретное действие: выровнять семантику.

\n

Другой случай: time.basis равен wall-clock, а closed отсутствует. Здесь нельзя написать «обработка заняла неизвестное время» и нельзя вычислить значение из текущих часов. Проверка должна вернуть stop-undetermined-time-boundary. Она не спорит о точности. Она фиксирует отсутствие основания для сравнения.

\n

Третий случай — coercion. Если адаптер превратил строку в число, округлил сумму или заменил пустое значение значением по умолчанию, результат уже не exact mapping. Приведение может быть правильным в конкретной программе, но оно требует правила, единицы и теста. До этого момента оно скрывает смысл и закрывает hand-off.

\n

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

\n
  1. Назовите одну операцию и версию схемы. Не начинайте с описания всех сервисов.
  2. Опишите value через tag и явные единицы: например, amountMinor и currency.
  3. Опишите error envelope целиком. Зафиксируйте code, retry и допустимое отсутствие кода.
  4. Выберите одну time basis и запишите ordered opened и closed. Не называйте ticks миллисекундами без источника.
  5. Добавьте по одной записи для PHP, JavaScript и D. Сверяйте каждый адаптер с contract.
  6. Запретите скрытое приведение. Для каждого преобразования добавьте именованное правило или верните stop.
  7. Прогоните положительный и отрицательные случаи через одну проверку. Сохраните status и nextAction без редакторской интерпретации.
  8. Передайте запись на следующий review только как synthetic hand-off. Реальную интеграцию проверяйте отдельным набором доказательств.
\n

Что можно утверждать после проверки

\n

Допустимая формулировка узкая: «фиксированная запись соответствует названным правилам и может перейти на synthetic review». Нельзя писать «PHP, JavaScript и D совместимы», «адаптер работает», «интеграция подтверждена» или «latency равна восьми». У модели нет runtime, транспорта, хоста, зависимостей, прав, пользовательских данных и production-метрик.

\n

Это не бюрократическая оговорка. Явная граница защищает решение от расширения смысла при копировании. Читатель видит, какой факт проверен, а какой ещё требует отдельного эксперимента. Если понадобится связать PHP-запрос и запись D, добавьте correlation id и проверьте его на реальном пути. Не выводите связь из одинакового времени, порядка строк или похожего JSON.

\n

Ограничения

\n

Модель не описывает ABI, сериализацию, nullable policy, иерархию классов, stack unwinding, сборку мусора, планировщик, retry конкретного клиента или схему регистрации сервисов. Эти свойства нельзя спрятать в поле details. Если свойство влияет на решение, назовите его отдельным полем и задайте проверку. Если назвать его нельзя, остановите границу.

\n

Модель также не заменяет контракт домена. Tag order-ready говорит о форме значения, но не доказывает, что бизнес действительно разрешает выдавать заказ. Доменный смысл проверяет владелец операции. Техническая проверка должна передать ему точное значение и не присваивать себе его решение.

\n

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

\n

Граница готова к synthetic hand-off, если выполнены все условия: есть schema version и operation; value имеет tag и единицы; error содержит named semantics, code и retry; time имеет одну basis и целые ordered ticks; присутствуют все три model labels; каждый адаптер сохраняет version, tag, error semantics, time basis и exact mapping; отрицательные случаи возвращают именованные stop; результат содержит observedEffect: none-observed.

\n

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

\n

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

\n" + "title": "PHP, JavaScript и D на одной границе: как доказать совместимость", + "excerpt": "Практический разбор расхождения между PHP, JavaScript и D: явный контракт значения, ошибки и времени, отрицательные проверки и границы вывода.", + "contentHtml": "

Часть запроса проходит через PHP, затем попадает в JavaScript, а результат обрабатывает компонент на D. Пользователь получает ошибку без понятной причины, а оператор не может связать запись D с исходным запросом. На границе всё выглядит правдоподобно: поля называются одинаково, JSON похож, а число времени совпадает. Но один участник возвращает значение, другой выбрасывает исключение, третий записывает код отдельно. Это не совместимость, а потеря смысла, которую пока не видно.

\n

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

\n

Вопрос, на который отвечает проверка

\n

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

\n

Такой подход отделяет факт от предположения. Факт — в записи есть schemaVersion: 'boundary-1', значение помечено тегом order-ready, а интервал задан логическими шагами от 100 до 108. Предположение — что этот объект действительно прошёл через PHP, JavaScript и D. Поля model с названиями языков не превращаются в доказательство запуска. Для последнего нужны логи, транспорт, версии сборок и тест конкретного приложения.

\n

Контракт начинается со смысла, а не с формы JSON

\n

JSON описывает синтаксическую форму, но не объясняет значение числа или строки. Число 4200 может быть суммой в минимальных единицах, лимитом, идентификатором или счётчиком. Строка ready может быть состоянием домена, текстом интерфейса или случайным результатом сравнения. Поэтому поле value.tag должно называться явно, а денежное значение — иметь единицу и валюту. Если потребитель угадывает смысл по имени поля, контракт уже неполон.

\n

Операция и версия нужны для защиты от тихой подмены. Контракт для fixed-order-decision нельзя автоматически применять к другому действию только потому, что там тоже есть amountMinor. Версия сообщает, по какому набору правил читать запись. Это не версия PHP, Node.js или компилятора D. Версии runtime и библиотек остаются отдельными атрибутами интеграционного теста.

\n

Ошибку тоже нужно описывать как данные о поведении. В примере error.semantics равен named-envelope, code может быть null, а retry имеет значение not-requested. Последняя строка не означает, что повтор безопасен: она лишь фиксирует, что данная запись не запрашивает повтор. Идемпотентность, эффект повторного вызова и политика клиента требуют отдельного контракта.

\n

Время должно иметь basis. Фиксированные логические шаги подходят для проверки порядка в заранее созданном объекте. Они не являются миллисекундами, latency или SLA. Для наблюдаемой длительности нужны источник часов, единицы измерения, границы интервала и правило обработки рассинхронизации. Подстановка текущего времени в пропущенное поле делает пример менее воспроизводимым и скрывает ошибку.

\n

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

\n

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

\n
const contract = {\n  schemaVersion: 'boundary-1',\n  operation: 'fixed-order-decision',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst participants = [\n  { model: 'php', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'javascript', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'd', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n];\n\nfunction review(record, items) {\n  if (record?.schemaVersion !== 'boundary-1' ||\n      record.operation !== 'fixed-order-decision' || items.length !== 3) {\n    return 'stop-incomplete-contract';\n  }\n  if (record.value?.tag !== 'order-ready' ||\n      record.value.currency !== 'RUB' || !Number.isInteger(record.value.amountMinor)) {\n    return 'stop-untagged-value';\n  }\n  if (record.error?.semantics !== 'named-envelope' ||\n      !Object.hasOwn(record.error, 'code') || !Object.hasOwn(record.error, 'retry')) {\n    return 'stop-ambiguous-error';\n  }\n  if (record.time?.basis !== 'fixed-logical-ticks' ||\n      !Number.isInteger(record.time.opened) || !Number.isInteger(record.time.closed) ||\n      record.time.opened > record.time.closed) {\n    return 'stop-undetermined-time-boundary';\n  }\n  const knownModels = new Set(['php', 'javascript', 'd']);\n  const exact = items.every((item) =>\n    knownModels.has(item.model) &&\n    item.version === record.schemaVersion &&\n    item.valueTag === record.value.tag &&\n    item.errorSemantics === record.error.semantics &&\n    item.timeBasis === record.time.basis &&\n    item.mapping === 'exact'\n  );\n  return exact ? 'accepted-fixed-contract' : 'stop-incomparable-adapter';\n}\n\nconsole.log(review(contract, participants));\nconst mixedError = participants.map((item) =>\n  item.model === 'javascript' ? { ...item, errorSemantics: 'thrown-value' } : item\n);\nconsole.log(review(contract, mixedError));
\n

Ожидаемый вывод — сначала accepted-fixed-contract, затем stop-incomparable-adapter. Первый результат означает только то, что все проверенные поля совпали с проектными литералами. Второй показывает, что один участник описывает ошибку иначе. Валидатор не пытается угадать, можно ли преобразовать исключение в envelope. Такое преобразование допустимо только как явно реализованный адаптер с собственными тестами и правилами.

\n

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

\n

Как читать отрицательный результат

\n

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

\n

Проверка времени должна быть особенно строгой. Если closed отсутствует, нельзя вычислять его из текущих часов. Если basis одного участника — epoch seconds, а другого — логические шаги, совпадающие числа не создают общего интервала. Если нужны реальные миллисекунды, пример следует заменить измерением на конкретном пути и отдельно указать часы, нагрузку, версию сборки и способ повторения.

\n

Нельзя исправлять отказ неявной coercion. Превращение строки в число, округление суммы или замена пустого значения default-ом может быть осмысленной операцией. Но она меняет границу данных. Её следует назвать, покрыть тестом на исходное и полученное значение и включить в версию адаптера. Пока правило не названо, статус exact будет ложным.

\n
Симптомы потери смысла, проверка и безопасное действие
СимптомВероятная причинаМинимальная проверкаДействие
Похожие JSON дают разные решенияНет версии или value tagСверить схему, tag, единицу и валютуСделать поля обязательными; не выводить смысл из shape
Один участник бросает ошибку, другие возвращают объектСмешаны режимы завершенияСравнить semantics, code и retryВвести явный адаптер или остановить передачу
В отчёте появилась latency из двух чиселЛогические шаги приняты за часыПроверить basis, источник и единицыУбрать вывод о скорости или провести измерение
Повтор создаёт вторую операциюRetry назван без идемпотентностиПроверить ключ операции и повторный эффектОстановить retry до отдельного решения
Неполная запись считается успешнойВалидатор подставляет defaultУдалить default и добавить missing-caseВернуть именованную причину отказа
Лог D нельзя связать с запросом PHPНет общего correlation idПроверить идентификатор в каждом событииДобавить его в новый контракт; не связывать по времени
\n

Где проходит граница ответственности

\n

Контракт отвечает за представление и правила передачи. Он не решает, разрешено ли выдавать заказ, имеет ли пользователь право на операцию или безопасно ли повторять вызов. Эти решения принадлежат доменному владельцу и должны иметь собственные условия и тесты. Метка order-ready описывает значение в технической записи, но не выдаёт бизнес-разрешение.

\n

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

\n

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

\n
Схема проверки контракта между PHP, JavaScript и D с отдельными ветками отказа при неполной записи и точного сопоставления
Схема показывает порядок чтения фиксированной записи: граница, семантика и точное сопоставление. Она не изображает сетевой вызов, запуск трёх runtime или результат production-наблюдения.
\n

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

\n
  1. Назовите одну операцию, владельца доменного смысла и версию контракта.
  2. Зафиксируйте value tag, формат числа, единицы, валюту и допустимые пустые значения.
  3. Опишите один режим ошибки. Отдельно укажите code, retry и условие, при котором повтор безопасен.
  4. Выберите time basis. Для latency назовите источник часов и единицы; для порядка используйте отдельные логические метки.
  5. Составьте по одному образцу для PHP, JavaScript и D. Сравнивайте каждый образец с контрактом.
  6. Добавьте отрицательные случаи: пропущенная версия, неизвестный tag, смешанная ошибка, отсутствующая граница времени и скрытое преобразование.
  7. Сохраните для каждого отказа код причины и поле, которое его вызвало. Общий статус adapter error не помогает исправлению.
  8. Проверьте сериализацию и транспорт отдельными тестами. Зафиксируйте версии runtime, библиотеки, сборки и окружения.
  9. Только после этого проверяйте реальный путь по логам и correlation id. Не приписывайте фиксированному примеру эффекты работающей системы.
\n

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

\n

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

\n

Валидатор также не измеряет производительность. Числа 100 и 108 — проектные целые литералы, показывающие порядок и интервал внутри fixture. Они не означают 8 миллисекунд, 8 секунд или любую другую физическую величину. Нельзя сравнивать такой объект с production latency и делать вывод о быстродействии D, PHP или JavaScript.

\n

Официальная спецификация отдельного языка не является сертификатом межъязыковой совместимости. Документация PHP описывает его исключения, спецификация ECMAScript — семантику JavaScript, спецификация D — собственные исключения и безопасность их обработки. Общий API подтверждается только контрактом приложения и тестом всех границ. Если важное условие нельзя выразить в контракте, область вывода нужно сузить, а не заполнить догадкой.

\n

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

\n

Граница готова к интеграционному тесту, если другой инженер без устного объяснения может назвать операцию, версию, смысл каждого значения, режим ошибки, правило retry и шкалу времени. Для полного и неполного объектов есть разные ожидаемые статусы. Каждый участник имеет идентификатор, версию и exact mapping либо описанное преобразование. Отрицательные случаи не превращаются в успех за счёт default-ов.

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/056.json b/editorial/agent-rewrites/056.json index 16ed3d4..2f62a7c 100644 --- a/editorial/agent-rewrites/056.json +++ b/editorial/agent-rewrites/056.json @@ -2,6 +2,6 @@ "index": 56, "slug": "editorial-2026-06-mechanism-multi-runtime", "title": "Один payload, три runtime: как сохранить смысл на границе", - "excerpt": "Похожая структура данных не делает PHP, JavaScript и D взаимозаменяемыми. Разбираем контракт type, error и time, отрицательный путь и проверяемый критерий совместимости.", - "contentHtml": "

Сервис принял заказ и вернул объект с полями amount, currency и status. PHP назвал статусом готовности строку ready. JavaScript обработал её как обычный результат. D получил тот же набор полей, но считает отсутствие кода ошибки отдельным состоянием. На границе всё выглядит одинаково. После сбоя команда видит три разных решения, а в логах остаётся один красивый JSON.

\n

Цена такой ошибки — не только неверное сообщение. Клиент может повторить уже принятый заказ, worker может пропустить отказ, а расследование свяжет события по совпадающим полям, хотя они относятся к разным состояниям. Исправление обычно начинается с догадки: добавить retry, привести значение к строке или считать пустой код успехом. Каждая такая догадка расширяет зону риска.

\n

Тезис статьи простой: общий boundary contract должен называть не только поля, но и их смысл. Для минимальной проверки достаточно разделить три независимые оси: type отвечает за вид значения, error — за режим завершения, time — за определённую шкалу и порядок. Если одна ось не задана или подменена другой, проверка должна остановиться.

\n

Почему одинаковый JSON не равен общему контракту

\n

JSON переносит форму. Он не переносит договор о поведении. Число 4200 может означать сумму в копейках, лимит, внутренний идентификатор или случайный счётчик. Строка ready может быть именем состояния, текстом для интерфейса или результатом нестрогого сравнения. Без названного типа consumer вынужден угадывать.

\n

Ошибки создают второй разрыв. Один runtime может вернуть объект результата с полем error. Другой может завершить операцию исключением. Третий может вернуть код и продолжить выполнение. Человек способен описать эти случаи одной фразой «обработка ошибки». Адаптеру такой фразы недостаточно: ему нужно знать, можно ли повторять операцию, сохранено ли значение и кто владеет решением.

\n

Время создаёт третий разрыв. Длительность из monotonic clock нельзя без оговорки сравнивать с календарным timestamp. Два числа без шкалы не доказывают latency. Даже одинаковые начало и конец могут быть только порядковыми метками внутри тестового объекта, а не наблюдением работающей системы.

\n
\"Матрица
Иллюстрация разделяет три вопроса boundary. Матрица показывает структуру проверки, а не сравнительную характеристику PHP, JavaScript и D и не результат запуска в конкретной среде.
\n

Три оси контракта

\n

type должен содержать named tag. Не выводите его из соседних полей. В примере order-ready — это фиксированная метка значения, а amountMinor и currency — дополнительные поля с собственной единицей и форматом. Если адаптер заменяет tag числом или оставляет его пустым, shape больше нельзя считать сохранённым.

\n

error должен описывать режим завершения. Удобная минимальная форма — semantics, code и retry. Значение code: null означает отсутствие кода в данном envelope. Оно не означает «в системе ошибок нет». Значение retry: not-requested не запускает повтор и не обещает, что повтор безопасен. Для этого нужны отдельные правила идемпотентности.

\n

time должен называть basis и обе границы интервала. Fixed logical ticks подходят для проверки порядка внутри заранее заданной записи. Они не являются миллисекундами. Если контракт требует наблюдаемую длительность, ему нужны источник измерения, единицы, точка начала и точка окончания. Нельзя подставить текущие часы, чтобы получить зелёный результат.

\n

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

\n

Учебный пример проверки

\n

Ниже приведён ограниченный JavaScript-пример. Он проверяет только объект в памяти и возвращает причину остановки. Он не запускает PHP или D, не вызывает сеть, не читает часы, не измеряет производительность и не подтверждает поведение сервиса. Его задача — показать форму fail-closed проверки.

\n
const contract = {\n  schemaVersion: 'boundary-1',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst adapters = [\n  { model: 'php', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'javascript', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'd', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n];\n\nfunction review(record, participants) {\n  if (!record.schemaVersion || participants.length !== 3) {\n    return 'stop-incomplete-contract';\n  }\n  if (record.time.basis !== 'fixed-logical-ticks' ||\n      record.time.closed === null || record.time.closed < record.time.opened) {\n    return 'stop-undetermined-time-boundary';\n  }\n  const valid = participants.every((item) =>\n    item.version === record.schemaVersion &&\n    item.valueTag === record.value.tag &&\n    item.errorSemantics === record.error.semantics &&\n    item.timeBasis === record.time.basis &&\n    item.mapping === 'exact'\n  );\n  return valid ? 'accepted-fixed-contract' : 'stop-incomparable-adapter';\n}\n\nconsole.log(review(contract, adapters)); // accepted-fixed-contract
\n

Пример сравнивает ровно те сведения, которые записаны в объекте. Он не делает вывод о типовой системе языка по полю model. Подписи php, javascript и d здесь лишь заранее названные участники матрицы. Версия runtime, библиотека, transport и формат сериализации в этот объект не входят.

\n

В реальном коде такой boundary нужно реализовать в согласованном контракте и покрыть тестами конкретного продукта. Нельзя скопировать функцию и объявить интеграцию проверенной. Учебный объект специально маленький: он помогает увидеть missing field и смешанную семантику, но не заменяет контракт API.

\n

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

\n
Признаки потери смысла на границе и минимальная реакция
СимптомПричинаПроверкаДействие
Одинаковый shape даёт разные решенияНет named value tag или версии схемыСверить tag, schemaVersion и единицу каждого поляДобавить обязательные поля; не выводить смысл из shape
Один участник бросает ошибку, другие возвращают envelopeСмешаны error semanticsСравнить режим завершения, code и retryВыбрать один boundary mode или остановить mapping
Отчёт говорит «быстрее», но метрики нетLogical ticks приняли за latencyПроверить basis, источник часов и обе границыУдалить вывод о скорости либо завести отдельное измерение
Повтор после таймаута создаёт второй заказRetry назван, но идемпотентность не определенаПроверить operation key и эффект повторного вызоваОстановить повтор; согласовать ключ и политику отдельно
«Успех» появляется при неполном объектеПроверка подставляет default вместо отказаУдалить default и прогнать missing-caseВернуть точную stop-причину владельцу контракта
\n

Отрицательный путь важнее зелёного примера

\n

Положительный объект удобен, но он почти ничего не говорит о дисциплине контракта. Настоящая проверка начинается с испорченной записи. Удалите schemaVersion. Ожидаемый результат — stop-incomplete-contract. Не подставляйте текущую версию автоматически: иначе тест перестанет замечать несовместимый участник.

\n

Замените у JavaScript-участника errorSemantics на thrown-value. Ожидаемый результат — stop-incomparable-adapter. Проверка не должна оборачивать исключение в envelope задним числом. Такое преобразование может быть правильным решением продукта, но тогда оно должно быть отдельным адаптером с названными правилами, а не скрытой операцией review.

\n

Измените time.basis на wall-clock и оставьте closed: null. Ожидаемый результат — stop-undetermined-time-boundary. Нельзя сказать «интервал неизвестен, но примерно короткий». В этом объекте нет факта, который поддерживает такую оценку.

\n

Такой путь защищает и от незаметной нормализации. Coercion может сделать данные удобнее для одного consumer, но скрыть различие между целым числом и tagged value. Если преобразование нужно, его надо назвать, версионировать и проверить как новую границу. Молчаливое приведение не является доказательством совместимости.

\n

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

\n
  1. Назовите одну операцию и владельца её смысла. Не начинайте с общего утверждения «три языка совместимы».
  2. Запишите schema version, value tag, единицы полей и допустимые пустые значения.
  3. Определите один error envelope или другой единый режим завершения. Отдельно назовите retry и условие его безопасности.
  4. Выберите time basis. Для логического порядка зафиксируйте обе границы; для latency укажите источник измерения и единицы.
  5. Составьте по одной записи для PHP, JavaScript и D. Сравнивайте каждую с контрактом, а не одну реализацию с другой.
  6. Запустите positive-case и четыре negative-case: missing schema, mixed error, неизвестный tag и незакрытый интервал.
  7. Для каждого отказа сохраните точную причину. Не заменяйте её общим «adapter error».
  8. Только после успешной проверки границы подключайте конкретный transport, runtime и наблюдение. Их результаты нельзя приписывать синтетическому объекту.
\n

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

\n

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

\n

Даже официальный документ языка отвечает на вопрос о данном языке, а не о совместимости трёх систем. Например, строгая типизация PHP может изменить момент отказа, completion record ECMAScript описывает семантику спецификации JavaScript, а D документирует собственную обработку ошибок. Ни один из этих фактов сам по себе не доказывает общий API.

\n

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

\n

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

\n

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

\n

Дополнительное условие — все три участника сохраняют contract shape без неявного приведения, а положительный результат не содержит утверждения о latency, deployment или реальном поведении среды. Если хотя бы одно поле приходится угадывать, проверка должна закончиться именованной stop-причиной. Это и есть полезный результат: команда видит границу знания до того, как похожий payload станет ошибочным действием.

\n

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

\n" + "excerpt": "Похожая структура данных не делает PHP, JavaScript и D взаимозаменяемыми. Разбираем контракт type, error и time, fail-closed проверку и границы вывода.", + "contentHtml": "

Наблюдаемый симптом — один и тот же заказ получает разные решения. В одном заказе три участника видят почти одинаковый JSON: amount, currency и status. PHP считает status=ready успешным завершением. JavaScript проверяет только наличие строки и продолжает обработку. D ждёт отдельный код результата и оставляет операцию незавершённой. На экране это один payload, но решения уже расходятся.

\n

Цена расхождения появляется после сбоя. Клиент может повторить уже принятый заказ, worker — пропустить отказ, а оператор — связать события по совпавшим полям, хотя они относятся к разным стадиям. Попытка быстро исправить ситуацию обычно выглядит невинно: привести значение к строке, подставить код по умолчанию или включить retry. Но каждое молчаливое преобразование переносит неопределённость дальше по цепочке.

\n

Здесь полезно проверять не сходство объектов, а сохранение смысла. Минимальная граница состоит из версии схемы, именованного типа значения, режима ошибки и шкалы времени. Если хотя бы одна ось не описана, адаптер должен остановиться с конкретной причиной. Такой подход не делает языки одинаковыми; он показывает, в каком месте они перестают быть сопоставимыми.

\n

Сначала отделим форму от смысла

\n

JSON описывает синтаксическую форму обмена, но не решает, что означает поле amount или можно ли повторить операцию. Число 4200 может быть суммой в копейках, лимитом или идентификатором. Строка ready может быть именем состояния, текстом интерфейса или значением, которое случайно прошло нестрогое сравнение. Одинаковый shape не является доказательством одинакового поведения.

\n

Поэтому полезная запись на границе называет смысл явно. В учебном примере value.tag фиксирует тип полезной нагрузки, amountMinor — целое число в минимальных денежных единицах, а currency — код валюты. Это проектные решения конкретного примера, а не свойства PHP, JavaScript или D. Их нужно закрепить в API-схеме и тестах своего продукта.

\n

Версия схемы отвечает на вопрос «какую форму мы сейчас проверяем». Она не заменяет версию runtime и библиотеки. Если PHP меняет правила приведения скаляров, а JavaScript или D иначе представляют ошибку, одна версия JSON не устраняет различие. Версия нужна, чтобы не подменять новый договор старым объектом; остальные зависимости проверяются отдельно.

\n
\"Матрица
Четыре строки отвечают на четыре разных вопроса: назван ли тип значения, описан ли режим ошибки, определена ли шкала времени и выполнено ли точное сопоставление. Красная точка означает остановку проверки, а не дефект конкретного языка.
\n

Четыре независимые оси контракта

\n

type должен быть именованным тегом, а не выводом из соседних полей. Без него адаптер не знает, является ли 4200 суммой или лимитом. Тег также не заменяет проверку содержимого: после order-ready всё равно нужно проверить диапазон суммы, код валюты и обязательные поля.

\n

error описывает не текст сообщения, а режим завершения. В примере есть semantics, code и retry. Пара code: null и semantics: named-envelope означает только отсутствие кода в этой записи. Она не доказывает, что в системе не было ошибки. Поле retry сообщает намерение или результат политики, но не делает повтор безопасным без ключа идемпотентности и правила побочных эффектов.

\n

time должен назвать шкалу и обе границы интервала. В локальном примере используются фиксированные логические такты: по ним можно проверить порядок opened <= closed. Это не миллисекунды и не измерение производительности. Для latency понадобятся источник часов, единицы измерения, точки старта и окончания, а также правило, где именно начинается операция.

\n

adapter фиксирует перевод между внутренним представлением и этим envelope. Участник не может объявить себя совместимым по одному имени: проверяются версия, tag, режим ошибки, шкала времени и способ mapping. Если в одном месте происходит coercion, это отдельное правило преобразования с тестами, а не «точное» сопоставление.

\n

Что действительно различается в runtime

\n

Официальная документация PHP прямо описывает важную ловушку: по умолчанию скалярные значения могут быть приведены к объявленному типу, а declare(strict_types=1) меняет проверку для вызовов из конкретного файла. Несовпадение может закончиться TypeError. Поэтому PHP-тип параметра нельзя автоматически считать описанием внешнего JSON-контракта: поведение зависит от места вызова и от того, где стоит граница сериализации.

\n

Спецификация ECMAScript описывает language types и completion records JavaScript. Это модель выполнения программы, а не готовый HTTP-envelope с полями code и retry. Преобразование исключения или результата в такой envelope — решение адаптера. Его нельзя приписать самому JSON или назвать общим свойством всех трёх runtime.

\n

Спецификация D описывает собственную модель ошибок и раскрутки стека. Она помогает понять поведение D-кода внутри его среды, но не задаёт внешний договор с PHP и JavaScript. На транспортной границе нужно отдельно решить, какие ошибки становятся error.code, что происходит с незавершённой операцией и кто может инициировать повтор.

\n

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

\n

Воспроизводимая fail-closed проверка

\n

Ниже — самостоятельный пример для Node.js 18+. Он проверяет объект в памяти и не вызывает сеть, часы, PHP или D. Все значения внутри него учебные. Смысл функции в другом: неполная запись получает именованную причину остановки, а положительный результат появляется только при полном наборе независимых признаков.

\n
const validRecord = {\n  schemaVersion: 'boundary-1',\n  operation: 'create-order',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst adapters = [\n  { model: 'php', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'javascript', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'd', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n];\n\nfunction checkEnvelope(record, participants) {\n  if (!record || record.schemaVersion !== 'boundary-1' ||\n      record.operation !== 'create-order') {\n    return 'stop-incomplete-contract';\n  }\n  const requiredModels = new Set(['php', 'javascript', 'd']);\n  const actualModels = new Set(participants.map((item) => item.model));\n  if (actualModels.size !== 3 ||\n      [...requiredModels].some((model) => !actualModels.has(model))) {\n    return 'stop-incomplete-adapter-set';\n  }\n  if (record.value?.tag !== 'order-ready' ||\n      !Number.isInteger(record.value?.amountMinor) ||\n      typeof record.value?.currency !== 'string') {\n    return 'stop-incomplete-value';\n  }\n  if (record.error?.semantics !== 'named-envelope' ||\n      !Object.hasOwn(record.error, 'code') ||\n      typeof record.error.retry !== 'string') {\n    return 'stop-incomplete-error';\n  }\n  if (record.time?.basis !== 'fixed-logical-ticks' ||\n      !Number.isInteger(record.time.opened) ||\n      !Number.isInteger(record.time.closed) ||\n      record.time.closed < record.time.opened) {\n    return 'stop-undetermined-time-boundary';\n  }\n  const valid = participants.every((item) =>\n    item.version === record.schemaVersion &&\n    item.valueTag === record.value.tag &&\n    item.errorSemantics === record.error.semantics &&\n    item.timeBasis === record.time.basis &&\n    item.mapping === 'exact'\n  );\n  return valid ? 'accepted-fixed-contract' : 'stop-incomparable-adapter';\n}\n\nconst copy = () => JSON.parse(JSON.stringify(validRecord));\nconsole.log(checkEnvelope(validRecord, adapters));\nconst unknownTag = copy();\nunknownTag.value.tag = 'order-paid';\nconsole.log(checkEnvelope(unknownTag, adapters));\nconst mismatchedAdapter = adapters.map((item) => ({ ...item }));\nmismatchedAdapter[1].valueTag = 'order-paid';\nconsole.log(checkEnvelope(validRecord, mismatchedAdapter));\n\n// accepted-fixed-contract\n// stop-incomplete-value\n// stop-incomparable-adapter
\n

Проверка начинается с операции и набора участников, поэтому три записи одного и того же runtime не проходят как «три среды». Затем она валидирует значение, ошибку и время по отдельности. Вызов Object.hasOwn не даёт превратить отсутствие кода в неявный default. На последнем шаге адаптеры сравниваются с записью, а не друг с другом: взаимное совпадение трёх одинаково ошибочных переводов не считается доказательством.

\n

Пример можно запустить, сохранив код в чистый файл и выполнив node checker.mjs. Для настоящего API следует заменить учебную запись схемой продукта, добавить проверку неизвестных полей и зафиксировать правила сериализации. Результат функции не является сертификатом совместимости: он показывает, что именно проверено и где проверка отказалась делать вывод.

\n

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

\n
Минимальная карта расследования неоднозначного payload
СимптомВероятная причинаПроверкаДействие
Одинаковый shape даёт разные решенияНе названы tag, версия или единицы полейСверить обязательные поля до бизнес-веткиРасширить envelope; не выводить смысл из shape
Один участник бросает ошибку, другие возвращают объектСмешаны режимы завершенияСопоставить semantics, code и момент завершенияЗадокументировать адаптер или остановить mapping
После таймаута создаётся второй заказretry есть, а идемпотентность не определенаПовторить вызов с тем же ключом на тестовой записиЗапретить автоматический повтор до правила эффекта
Отчёт говорит «быстрее», но метрики нетЛогические такты приняты за latencyПроверить basis, часы, единицы и границыУдалить вывод о скорости или завести отдельное измерение
Неполный объект считается успешнымВалидатор подставляет defaultУдалить поле и проверить точную stop-причинуСделать обязательность явной и покрыть missing-case
\n

Отрицательный путь должен быть частью договора

\n

Положительная запись показывает только счастливую ветку. Дисциплину проверяют изменения, которые инженер обязан отвергнуть. Удалите schemaVersion или замените operation: функция возвращает stop-incomplete-contract. Она не угадывает версию по текущей сборке и не пытается «помочь» вызывающему коду.

\n

Замените value.tag на неизвестное значение. Ожидаемая причина — stop-incomplete-value: запись больше не соответствует выбранному типу. Чтобы проверить несовместимый адаптер, измените у JavaScript-участника valueTag. Тогда функция вернёт stop-incomparable-adapter. То же происходит, если JavaScript сообщает thrown-value вместо named-envelope. Нельзя объявить эти варианты равными только потому, что оба заканчиваются словом «ошибка».

\n

Поставьте time.closed: null или поменяйте basis на wall-clock. Результат — stop-undetermined-time-boundary. Если нужна настоящая длительность, добавьте отдельный контракт с единицами и источником измерения. Не переводите условный такт в миллисекунды задним числом.

\n

Проверьте также дубликат модели: замените D-участника вторым PHP-участником. Функция вернёт stop-incomplete-adapter-set. Это маленькая деталь, но без неё матрица доказывает лишь три строки данных, а не участие трёх заявленных runtime. Такой отрицательный тест должен жить рядом с положительным и запускаться на каждое изменение схемы.

\n

Как встроить проверку в реальный обмен

\n
  1. Назовите операцию и владельца её бизнес-смысла. «Три языка совместимы» — слишком широкое утверждение для теста.
  2. Опишите версию схемы, tag, единицы чисел, обязательные поля, допустимый null и поведение неизвестных полей.
  3. Разделите результат и ошибку. Для каждого error code укажите, сохранён ли эффект, разрешён ли повтор и кто принимает решение.
  4. Выберите одну шкалу времени для каждого измерения. Порядковые метки, календарные даты и latency не смешивайте в одном поле.
  5. Зафиксируйте вход и выход каждого адаптера. Логируйте идентификатор операции, но не подменяйте им доказательство корректности.
  6. Запустите положительный случай и минимум четыре отрицательных: неполная схема, неизвестный tag, смешанная ошибка и незакрытый интервал.
  7. Проверьте побочные эффекты отдельно: повторная доставка, транзакция, дедупликация и восстановление после таймаута не следуют из формы JSON.
  8. Только после этого подключайте transport, конкретные версии runtime и наблюдаемость. Результат локального теста не переносите на production без отдельного измерения.
\n

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

\n

Матрица проверяет согласованность выбранного envelope, а не эквивалентность языков. Она не описывает сборщик мусора, правила ABI, сериализатор, планировщик, права, транзакции, порядок доставки сообщений или лимиты сети. Каждое из этих свойств может изменить результат и требует собственной проверки.

\n

Даже официальный документ языка отвечает на вопрос о языке, а не о вашем API. PHP может привести скаляр до вызова функции; JavaScript может завершить функцию значением или исключением; D использует свою модель ошибок. Между этим поведением и внешним JSON находится ваш код. Именно его контракт и тесты должны объяснить, что увидит соседний участник.

\n

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

\n

Наконец, наличие error.code не доказывает, что операция безопасна для повтора. Для этого нужны идемпотентный ключ, граница фиксации эффекта и тест повторной доставки. Если такие условия не помещаются в текущую запись, вывод ограничивается проверкой формы и не распространяется на бизнес-результат.

\n

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

\n

Граница готова к интеграционному тесту, если инженер без устного пояснения может назвать операцию, версию схемы, допустимый tag, единицы каждого поля, режим ошибки, смысл retry и шкалу времени. Для каждого отрицательного случая заранее известна точная причина остановки.

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/057.json b/editorial/agent-rewrites/057.json index fc8e0da..40e1ea8 100644 --- a/editorial/agent-rewrites/057.json +++ b/editorial/agent-rewrites/057.json @@ -1,7 +1,7 @@ { "index": 57, "slug": "editorial-2026-06-practice-multi-runtime", - "title": "PHP, JavaScript и D: как удержать общий контракт на границе runtime", - "excerpt": "Когда один ответ проходит через PHP, JavaScript и D, похожие поля ещё не означают одинаковый смысл. Разбираем узкий контракт, fail-closed проверку и границу между учебной моделью и реальной интеграцией.", - "contentHtml": "

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

\n

Тезис простой: общий контракт нужно проектировать на границе задачи, а не выводить из сходства языков. Для учебной проверки достаточно одного объекта в памяти. В нём надо явно назвать операцию, вид значения, семантику ошибки, шкалу времени и правила адаптеров. Если хотя бы одно поле нельзя сравнить, проверка должна остановиться. Такой результат подтверждает только внутреннюю согласованность модели. Он не подтверждает работу PHP, JavaScript, D или production-сервиса.

\n

Почему одинаковый payload обманывает

\n

JSON-подобная форма скрывает решения. Число может означать деньги в минимальных единицах, счётчик или результат преобразования. Пустое поле может означать отсутствие значения, ошибку или значение по умолчанию. Время может быть timestamp, длительностью или логическим порядком событий. Если контракт не называет эти свойства, каждый адаптер заполняет пробел своим правилом.

\n

Нужен узкий boundary contract. Он не пытается описать всю систему и не переносит внутренние классы, stack trace, сборщик мусора или планировщик. Он отвечает на один вопрос: сохраняют ли три представления одну заранее названную форму. Поэтому в нём нет неявного default. Отсутствующее поле ведёт к отказу, а не к удобной подстановке.

\n

Из чего состоит граница

\n

В примере операция называется fixed-order-decision. Поле value.tag отделяет вид значения от его представления. amountMinor: 4200 — учебное целое число; оно не объявляет денежный протокол и не должно автоматически превращаться во float. Поле error использует именованный конверт: в нём есть семантика, код и правило повтора. Это не объект исключения и не текст сообщения.

\n

Время задаётся двумя упорядоченными логическими отметками. Числа 100 и 108 дают разность восемь внутри учебной шкалы. Они не являются timestamp и не показывают latency. Каждый адаптер получает ту же версию схемы, тот же tag, ту же семантику ошибки, ту же шкалу времени и результат exact. Приведение типа скрывает потерю смысла, поэтому его надо отклонять.

\n
Минимальные поля общего контракта
ПолеЗачем оно нужноКогда остановиться
schemaVersionСвязывает верхний объект и адаптеры.Версия пустая или различается.
value.tagНазывает вид значения до преобразования.Tag отсутствует или подменён.
errorФиксирует code и retry без object identity.Нет именованного конверта.
timeЗадаёт одну сравнимую шкалу.Нет двух упорядоченных отметок.
mappingПоказывает сохранение формы.Используется coercion вместо exact.
\n
\"Три
Схема показывает структуру границы. Она не изображает соединение процессов и не является трассировкой реальной системы.
\n

Учебный пример в памяти

\n

Ниже выполняется только JavaScript-код, который читает заранее заданный объект. Строки php, javascript и d — метки взглядов на форму, а не запущенные процессы. Пример полезен для проверки правил и отрицательных веток. Он не доказывает совместимость библиотек, транспортов или окружений.

\n
const record = {\n  id: 'named-contract-v1',\n  schemaVersion: 'fixed-boundary-1',\n  contract: {\n    operation: 'fixed-order-decision',\n    value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n    error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n    time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n  },\n  adapters: [\n    { model: 'php', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n    { model: 'javascript', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n    { model: 'd', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n  ]\n};\n\nconst models = new Set(record.adapters.map(({ model }) => model));\nconst accepted =\n  record.schemaVersion === 'fixed-boundary-1' &&\n  record.contract.time.closed >= record.contract.time.opened &&\n  ['php', 'javascript', 'd'].every((model) => models.has(model)) &&\n  record.adapters.every((adapter) =>\n    adapter.contractVersion === record.schemaVersion &&\n    adapter.valueTag === record.contract.value.tag &&\n    adapter.errorSemantics === record.contract.error.semantics &&\n    adapter.timeBasis === record.contract.time.basis &&\n    adapter.mapping === 'exact'\n  );\n\nconsole.log({ accepted, externalEffect: 'not-checked' });
\n

Положительный результат означает: поля учебного объекта соответствуют названным правилам. externalEffect: not-checked удерживает смысл результата рядом с кодом. Если его убрать, читатель легко примет accepted: true за доказательство, что три системы связаны.

\n

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

\n
Диагностика несогласованной границы
СимптомПричинаПроверкаДействие
Пропущенный amount читается как ноль.Нет отдельного tag для отсутствия.Сверить value.tag и наличие поля.Добавить именованный вариант или остановить проверку.
Один адаптер хранит thrown value.Смешаны semantics ошибки.Сравнить errorSemantics буквально.Выровнять конверт или вернуть stop-incomparable-adapter.
Для одного ответа считают duration.Нет общей шкалы и закрывающей отметки.Проверить basis, opened и closed.Задать ordered fixed ticks; не подставлять часы.
Адаптер возвращает похожее число.Форма прошла coercion.Проверить mapping на exact.Убрать приведение или описать новое поле и версию.
Пример называют интеграционным тестом.Метки моделей приняли за процессы.Перечислить реально запущенные компоненты.Сузить вывод до проверки объекта в памяти.
\n

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

\n
  1. Назовите одну операцию. Не смешивайте в одном объекте заказ, платёж и доставку.
  2. Зафиксируйте версию схемы и tag значения. Не используйте «любой JSON».
  3. Опишите ошибку отдельным именованным конвертом: semantics, code и retry.
  4. Выберите одну шкалу времени и запишите обе упорядоченные отметки.
  5. Добавьте по одной записи для PHP, JavaScript и D. Сверьте поля буквально.
  6. Проверьте exact mapping. Любая скрытая конверсия должна вернуть отказ.
  7. Прогоните положительный и отрицательные варианты. Сохраните status и точную причину.
  8. Сформулируйте результат в пределах наблюдения: «форма объекта согласована», а не «интеграция работает».
\n

Отрицательный путь важнее happy path

\n

Пустая версия или отсутствующий адаптер должны вернуть stop-incomplete-contract. Не надо принимать частичный объект ради продолжения разбора. Если JavaScript записывает thrown-value, а два других адаптера используют named-envelope, результат — stop-incomparable-adapter. Это не утверждение о поведении языков. Это точное описание несовпадения полей.

\n

Если closed отсутствует или basis равен wall-clock, верните stop-undetermined-time-boundary. Нельзя восстановить длительность из незаписанного события и нельзя превратить учебные ticks в метрику. Если новые требования делают эти поля недостаточными, создайте новую версию контракта. Не прячьте смысл в поле metadata.

\n

Ограничения

\n

Модель не описывает nullable semantics, ABI, сериализацию, transport, версии пакетов, доступы, retry конкретного клиента или бизнес-значение заказа. Она не запускает PHP, D или отдельный JavaScript runtime, не читает сеть и диск, не использует часы, не собирает telemetry, trace или profile и не содержит пользовательских данных. Поэтому из неё нельзя вывести latency, SLA, безопасность, совместимость релиза или готовность deploy.

\n

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

\n

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

\n

Материал и его пример готовы, если независимый читатель может повторить проверку по одному объекту и получить одно из двух: accepted с перечисленными полями или точный stop reason. При accepted все три model label присутствуют, версия совпадает, tag и error semantics совпадают, время упорядочено, mapping равен exact, а итог прямо говорит externalEffect: not-checked. При отказе причина указывает на конкретное недостающее поле. Ни один результат не использует слова «интеграция подтверждена» без отдельного runtime-доказательства.

\n

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

" + "title": "PHP, JavaScript и D: как не потерять смысл на общей границе", + "excerpt": "Три runtime могут передавать один payload и всё равно принимать разные решения. Разбираем узкий контракт результата, ошибки, времени и диагностики с воспроизводимой fail-closed проверкой.", + "contentHtml": "

На границе одного сервиса с другим ответ выглядит одинаково: PHP сформировал заказ, JavaScript показал его в интерфейсе, а обработчик на D получил те же поля для следующего шага. Проблема появляется не в формате, а в решении. Один потребитель считает отсутствие error успехом, второй ждёт исключение, третий подставляет ноль вместо пропущенной суммы.

\n

Цена расхождения быстро становится практической. Интерфейс может показать готовый заказ, worker — повторить уже выполненную операцию, а расследование не свяжет запись D с исходным запросом PHP. Команда видит один JSON и спорит о языках, хотя сначала надо ответить на более узкий вопрос: какие значения, состояния и правила этот JSON обязан сохранять?

\n

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

\n

JSON переносит форму, но не решение

\n

JSON удобен именно своей малой грамматикой: объект, массив, строка, число, логическое значение и null. RFC 8259 описывает его как текстовый формат обмена структурированными данными. Но в этих типах нет ответа на вопросы «можно ли повторить операцию», «что означает пустое поле» и «кто владеет отказом».

\n

Даже поле amount: 4200 не сообщает единицу. Это могут быть копейки, рубли, лимит или внутренний счётчик. Число также имеет границу переносимости: RFC 8259 отдельно отмечает точное согласование целых чисел в диапазоне от -(2**53)+1 до (2**53)-1 для реализаций с IEEE 754 binary64. Значит, «число в JSON» — ещё не денежный тип. Единицу и допустимый диапазон задаёт контракт приложения.

\n

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

\n
Что добавляет контракт поверх JSON
СлойПримерВопрос для проверки
Формаresult.tag и набор полейОтвет можно разобрать без догадок?
ЕдиницаamountMinor в RUBОдинакова ли шкала значения?
Состояниеoutcome.kind: acceptedЭто успех, отказ или незавершённая операция?
Времяbasis: logical-ticksМожно ли сравнить начало и конец?
СвязьcorrelationIdПо какому ключу искать одну операцию?
Преобразованиеmapping: exactЗначение сохранено или незаметно изменено?
\n
\"PHP,
Схема показывает состав boundary contract. Стрелки обозначают проверку формы записи, а не сетевое соединение и не запуск трёх runtime.
\n

Сначала зафиксировать одну операцию

\n

Широкий объект «данные заказа» плохо проверяется. В нём смешиваются бизнес-решение, транспортные детали, диагностические поля и состояние побочного эффекта. Для первой версии лучше выбрать одну операцию, например fixed-order-decision, и описать только результат этой операции.

\n

У каждого поля должен быть владелец смысла. Producer отвечает за то, что order-ready означает именно готовый результат, а не текст для интерфейса. Consumer не должен угадывать смысл по имени поля или по тому, что значение похоже на знакомый тип. Он принимает только известную версию и явно отказывается от незнакомой.

\n

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

\n

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

\n

Значение. Поле result.tag отделяет вид результата от его хранения. Для суммы нужны amountMinor, currency и правило диапазона. Не следует превращать пропущенную сумму в ноль: это два разных состояния, и у них должны быть разные tag или явное состояние отсутствия.

\n

Завершение. Поле outcome.kind отвечает на вопрос, чем закончилась операция. В примере допустимы accepted и rejected, а error содержит стабильный code и правило retry. Текст сообщения можно показывать человеку, но нельзя делать его единственным ключом для автоматики.

\n

Механизмы языков здесь различаются. PHP Manual описывает throw, catch и подъём исключения по стеку до обработчика. Спецификация ECMAScript использует Completion Record с типами normal и throw для описания значения и передачи управления. Документация D также строит обработку вокруг исключений и размотки стека. Эти источники объясняют механизмы внутри языков, но не создают общего протокола. На границе исключение надо явно сопоставить с полями envelope либо вернуть отказ.

\n

Время. logical-ticks в примере нужны только для проверки порядка: closed: 108 больше opened: 100. Это не миллисекунды и не измерение задержки. Если продукту нужна длительность, контракт должен назвать источник часов, единицы, точку старта и точку окончания. Календарная дата, monotonic clock и порядковая отметка решают разные задачи.

\n

Связь. correlationId связывает записи одной операции, но не доказывает, что запрос дошёл до следующего компонента. Уникальный идентификатор помогает искать события; доказательство доставки требует отдельного наблюдаемого результата. Не надо восстанавливать связь по совпавшему времени или одинаковой сумме.

\n
Минимальная семантическая матрица
ОсьЯвное полеНеверная подменаОстановка
Результатtag: order-readyВыводить тип по наличию amountMinorstop-unknown-result-tag
Ошибкаkind, code, retryСчитать отсутствие исключения успехомstop-mixed-outcome
Времяbasis, opened, closedНазывать ticks миллисекундамиstop-unknown-time-basis
СвязьcorrelationIdИскать событие по timestampstop-missing-correlation
\n

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

\n

Ниже обычный JavaScript без внешних зависимостей. Он проверяет заранее заданные literals, поэтому его можно сохранить в boundary-check.mjs и выполнить командой node boundary-check.mjs. Запись с меткой php не запускает PHP, а запись d не запускает D: это участники проверочной матрицы.

\n
const contract = {\n  schemaVersion: 'boundary-1',\n  operation: 'fixed-order-decision',\n  result: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  outcome: { kind: 'accepted', error: null, retry: 'never' },\n  time: { basis: 'logical-ticks', opened: 100, closed: 108 },\n  correlationId: 'demo-order-42'\n};\n\nconst adapters = [\n  { runtime: 'php', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' },\n  { runtime: 'javascript', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' },\n  { runtime: 'd', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' }\n];\n\nconst expectedRuntimes = new Set(['php', 'javascript', 'd']);\n\nfunction review(input, peers) {\n  if (input?.schemaVersion !== 'boundary-1' ||\n      input.operation !== 'fixed-order-decision') {\n    return { status: 'stop-incomplete-contract' };\n  }\n  if (input.result?.tag !== 'order-ready' ||\n      !Number.isInteger(input.result.amountMinor) ||\n      !input.result.currency) {\n    return { status: 'stop-unknown-result-tag' };\n  }\n  if (input.outcome?.kind !== 'accepted' || input.outcome.error !== null ||\n      input.outcome.retry !== 'never') {\n    return { status: 'stop-mixed-outcome' };\n  }\n  if (input.time?.basis !== 'logical-ticks' ||\n      !Number.isInteger(input.time.opened) ||\n      !Number.isInteger(input.time.closed) ||\n      input.time.closed < input.time.opened) {\n    return { status: 'stop-unknown-time-basis' };\n  }\n  const runtimes = new Set(peers.map((peer) => peer.runtime));\n  if (runtimes.size !== expectedRuntimes.size ||\n      [...expectedRuntimes].some((runtime) => !runtimes.has(runtime))) {\n    return { status: 'stop-missing-adapter' };\n  }\n  const invalid = peers.find((peer) =>\n    peer.version !== input.schemaVersion ||\n    peer.resultTag !== input.result.tag ||\n    peer.outcomeKind !== input.outcome.kind ||\n    peer.timeBasis !== input.time.basis ||\n    peer.mapping !== 'exact'\n  );\n  if (invalid) {\n    return { status: 'stop-incomparable-adapter', runtime: invalid.runtime };\n  }\n  return {\n    status: 'contract-consistent',\n    elapsedTicks: input.time.closed - input.time.opened,\n    externalSystems: 'not observed'\n  };\n}\n\nconsole.log(review(contract, adapters));\nconst coerced = adapters.map((peer) =>\n  peer.runtime === 'd' ? { ...peer, mapping: 'coerce' } : peer\n);\nconsole.log(review(contract, coerced));
\n

Положительный вывод означает только три вещи: поля одной записи прошли названные проверки, три model label присутствуют, а интервал упорядочен. Вторая строка должна вернуть stop-incomparable-adapter, потому что D-представление изменяет значение через неописанное преобразование. Если запустить код на Node.js, получится проверяемый результат, но не тест трёх языков и не тест сетевого обмена.

\n

Совместимость версий и правило изменения

\n

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

\n

Добавление нового поля обычно безопаснее, если старый consumer обязан его игнорировать и это правило записано. Переименование amountMinor, изменение единицы или превращение null в допустимый ноль — уже изменение смысла. Для такого шага нужна новая версия либо явная миграция. Поле metadata не должно становиться складом неоговорённых исключений.

\n

Полезно держать отдельные fixtures для нормального результата, отказа, неполного объекта и неизвестной версии. В каждом fixture фиксируются вход, ожидаемый status и причина. Тогда изменение адаптера вызывает понятный diff теста, а не спор по логам после выката.

\n

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

\n
Рабочая диагностика boundary contract
СимптомПроверкаДействие
Пропущенная сумма стала нулёмСверить tag, наличие поля и единицуВернуть отказ или ввести отдельный вариант отсутствия
Интерфейс видит успех, worker повторяет операциюСравнить outcome.kind, error.code и retryСделать решение явным и проверить идемпотентность отдельно
Лог D не находится по запросу PHPПроверить общий correlationId на каждом переходеДобавить идентификатор в новую версию и прокинуть его без замены
Одинаковое время даёт разные выводыСверить time.basis и обе точки интервалаНе вычислять latency из логических ticks
Один адаптер «почти» совпалПроверить mapping на exactОписать преобразование отдельным правилом либо остановить обмен
\n

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

\n
  1. Назвать одну операцию и владельца её смысла.
  2. Выбрать версию и выписать обязательные поля: tag, единицы, outcome, time и correlationId.
  3. Зафиксировать допустимые значения и точные stop reasons для неполного и неизвестного входа.
  4. Собрать по одному fixture на accepted, rejected, missing field, wrong unit и incompatible mapping.
  5. Реализовать адаптеры так, чтобы каждое преобразование было видно в коде и тесте.
  6. Проверить сериализацию: уникальные имена, допустимые JSON-значения, диапазоны чисел и кодировку.
  7. Проверить цепочку на реальном стенде отдельным тестом: транспорт, права, повтор, корреляцию и наблюдаемость.
  8. Только после этого обсуждать нагрузку, задержку и готовность выпуска. Boundary-validator сам по себе этих свойств не измеряет.
\n

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

\n

Пример намеренно мал. Он не проверяет ABI, сериализацию конкретной библиотеки, кодировку транспорта, версии PHP/Node/D, схему базы, права, таймауты, повтор после частичного побочного эффекта, безопасность, нагрузку или SLA. Он также не показывает, что три компонента действительно обменялись данными. Для этого нужны реальные точки входа, тестовый стенд, логи или трассировка и известный владелец результата.

\n

Фиксированные logical ticks нельзя превращать в latency, а correlationId — в доказательство доставки. amountMinor с валютой не заменяет правила округления, возврата и финансового учёта. Envelope с retry: never не доказывает идемпотентность операции. Эти свойства должны пройти свои проверки на уровне продукта.

\n

Если контракт должен поддержать иной результат, другую шкалу времени или новый способ обработки ошибки, это не повод молча ослабить validator. Добавьте поле, версию и fixture, затем повторите проверку. Fail-closed путь сохраняет неизвестное состояние видимым для владельца и не выдаёт удобное значение за подтверждённый смысл.

\n

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

\n

Граница подготовлена к следующему инженерному тесту, если независимый разработчик может взять fixture и получить тот же status без доступа к истории переписки. У записи есть одна операция и версия; результат имеет tag и единицы; outcome отделён от result; time имеет basis и две упорядоченные точки; correlationId сохраняется; каждый адаптер проходит exact mapping; отрицательные варианты возвращают конкретные stop reasons.

\n

После этого ещё нельзя писать, что PHP, JavaScript и D совместимы вообще. Можно сказать только: фиксированная запись соответствует выбранным правилам и готова перейти к отдельной проверке транспорта и среды. Такое утверждение уже достаточно полезно: оно показывает, что проверено, где заканчивается доказательство и какой следующий эксперимент нужен.

\n

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

" } diff --git a/editorial/agent-rewrites/058.json b/editorial/agent-rewrites/058.json index 71d5945..d4f5f3a 100644 --- a/editorial/agent-rewrites/058.json +++ b/editorial/agent-rewrites/058.json @@ -1,7 +1,7 @@ { "index": 58, "slug": "editorial-2026-05-field-systems-performance", - "title": "Низкий CPU, длинный запрос: где возникает задержка", - "excerpt": "Пользователь ждёт ответ, хотя CPU почти свободен. Разбираем очередь, вложенные интервалы, сопоставимую нагрузку и отказ от ложной оптимизации.", - "contentHtml": "

Пользователь ждёт страницу десять секунд, а график CPU держится на двадцати процентах. Разработчик видит свободный процессор и меняет запрос, добавляет поток или увеличивает таймаут. Иногда это случайно скрывает симптом. Часто задержка остаётся: запрос ждал допуска в очередь, соединение с базой или ответ внешней системы. Цена ошибки — лишний релиз, рост нагрузки и потеря исходного сигнала. После изменения уже трудно восстановить исходные условия сравнения.

\n

Тезис простой: низкая загрузка CPU не опровергает медленный запрос. Сначала разложите end-to-end интервал на наблюдаемые части и назовите границу сравнения. Только после этого выбирайте действие. Один trace показывает структуру пути. Он не доказывает, что изменение ускорит систему.

\n

Механизм: задержка не равна работе процессора

\n

Общее время запроса включает ожидание и работу. Запрос может стоять в очереди, пока CPU свободен. Он может ждать соединение, блокировку строки, диск, DNS, TLS или ответ удалённого сервиса. В эти моменты процессор не обязан быть занят. Метрика CPU отвечает на вопрос о занятости вычислительного ресурса, но не о времени ответа конкретного запроса.

\n

Trace отделяет участки пути, если дерево полно и интервалы используют одну временную основу. Root span задаёт end-to-end границу. Дочерний span показывает названную операцию внутри неё. Если дочерний интервал не покрывает разницу, остаток остаётся неизвестным. Его нельзя без отдельного сигнала назвать очередью, сетью или базой.

\n

Сравнение требует второй границы. Записи до и после изменения должны иметь один класс входа, одинаковое число запросов, одинаковую конкурентность и одинаковую форму данных. Если один прогон обрабатывает 12 запросов при concurrency 3, а второй — 24 при concurrency 6, разница времени ничего не говорит об изменении кода. Более короткий интервал может означать другую нагрузку.

\n
Проверка задержки: полный trace, контрольная граница нагрузки, контрпример и остановка при нехватке данных
Схема связывает путь запроса с границей сравнения. Если одного условия не хватает, вывод о причине задержки прекращается. Это учебная схема, а не измерение.
\n

Учебный пример: не перепутать очередь с причиной

\n

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

\n
const trace = {\n  root: { id: 'root-01', start: 0, end: 1000 },\n  spans: [\n    { id: 'queue-01', parent: 'root-01', name: 'admission-queue', start: 40, end: 560 },\n    { id: 'db-01', parent: 'root-01', name: 'db-call', start: 570, end: 720 },\n    { id: 'catalog-01', parent: 'root-01', name: 'catalog-call', start: 730, end: 930 }\n  ],\n  load: { cohort: 'load-a', requests: 12, concurrency: 3, shape: 'read-shape-a' }\n};\n\nfunction inspectTrace(input) {\n  const ids = new Set(input.spans.map((span) => span.id));\n  const connected = input.spans.every((span) =>\n    span.parent === input.root.id || ids.has(span.parent)\n  );\n  const ordered = input.spans.every((span) =>\n    Number.isFinite(span.start) && Number.isFinite(span.end) && span.end >= span.start\n  );\n\n  if (!connected) return { status: 'stop-incomplete-trace' };\n  if (!ordered) return { status: 'stop-invalid-interval' };\n  return { status: 'observation-ready', claim: 'not-measured' };\n}\n\nconsole.log(inspectTrace(trace));
\n

Результат observation-ready означает только, что учебная запись связна и содержит интервалы. Queue span занимает 520 условных единиц из 1000. Это повод проверить очередь отдельным сигналом. Код не доказывает, что очередь является корнем задержки, что база виновата или что удаление очереди ускорит пользователя.

\n

Отрицательный путь важнее короткого положительного. Если у span parent равен missing-01, функция возвращает stop-incomplete-trace. Если записи до и после изменения используют разные поля load, их нельзя сравнивать. Если в записи стоит effect: 'faster-after-change', это не измерение. Такое утверждение нельзя принимать без наблюдаемых данных.

\n

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

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
CPU низкий, запрос медленныйОчередь, блокировка или внешний ответ входит в end-to-end времяОткрыть trace и разделить ожидание, локальную работу и дочерние вызовыНазвать только покрытый span; неизвестный остаток оставить unknown
Самый длинный span совпал с пиком latencySpan включает ожидание upstream или retryПроверить parent/child, status, retry count и дочерние интервалыНе объявлять span причиной; добавить недостающую границу
Вторая запись короче первойИзменилась нагрузка, cohort или форма входаСверить requests, concurrency, cohort и input shapeСнять сравнение и повторить с одной control boundary
Дочерний span без parentПотеря записи, ошибка экспорта или неверный IDПроверить полный экспорт, уникальность ID и формат связиВернуть stop; не дорисовывать дерево по времени
После изменения есть одна короткая записьНет сопоставимой пары и распределения наблюденийСравнить тот же сценарий до и после на заданном окнеНазвать observation, а не improvement
\n

Как читать интервалы

\n

Сначала найдите root span и его границы. Затем проверьте, что каждый дочерний span имеет существующего родителя, начало не позже конца, а единицы времени совпадают. Интервалы могут перекрываться. Нельзя складывать все длительности и получать время ответа: параллельные операции будут посчитаны дважды.

\n

Если root длится 1000 условных единиц, очередь — 520, база — 150, а каталог — 200, сумма дочерних интервалов равна 870. Она не означает, что оставшиеся 130 — сеть. Часть времени могла пересекаться, а часть могла прийтись на неразмеченную работу. Корректная формулировка: «в записи есть 130 единиц, которые не покрыты названными span-ами». Их нельзя приписывать компоненту без отдельной границы.

\n

Время ожидания и время исполнения также нельзя смешивать. База могла выполнить запрос быстро после освобождения соединения. Внешний вызов мог вернуть ответ быстро, но запрос долго ждал его начала. Название span должно отражать проверенное содержание. db-call не равно «всё время до базы», если выдача соединения записывается отдельно.

\n

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

\n
  1. Зафиксируйте исходный симптом: маршрут, метод, статус, длительность, размер ответа, timestamp и идентификатор запроса.
  2. Сохраните trace до изменения кода или конфигурации. Отметьте root, дочерние операции, пропуски и неизвестные интервалы.
  3. Проверьте связность дерева и единицы времени. Отдельно отметьте перекрывающиеся span-ы; не складывайте их механически.
  4. Назовите контрольную границу: cohort, число логических запросов, concurrency и форму входных данных.
  5. Разделите ожидание и работу. Не называйте остаток причиной, пока для него нет отдельного сигнала.
  6. Сформулируйте две конкурирующие гипотезы. Для каждой запишите проверку, которая может её опровергнуть.
  7. Проверьте отрицательный случай: отсутствующий parent, неизвестная задержка или другая нагрузка должны вернуть точный stop.
  8. Измените один фактор. Повторите тот же сценарий и сохраните записи до и после рядом.
  9. Сопоставьте исходный симптом с соседними сигналами: ошибки, таймауты, очередь, throughput и потребление ресурсов. Не заменяйте пользовательскую задержку одним CPU-графиком.
\n

Что следует из записи

\n

Узкий результат может быть полезным. Например: «В trace-01 при load-a root равен 1000 условных единиц. Названный queue span занимает 520. Дерево связано. Сравнение до и после не выполнялось». Это указывает на очередь как на место для отдельной проверки. Формулировка не содержит обещания исправления.

\n

Сильнее звучит, но не следует из записи: «очередь стала bottleneck», «изменение БД ускорит путь» и «latency снизилась». Для каждого утверждения нужна отдельная граница доказательства. Нельзя получить контрфактический эффект из одного trace: он не показывает, что произошло бы без выбранного вызова или при другой конкуренции.

\n

Ограничения

\n

Sampling может убрать нужный span. Collector может потерять запись или доставить события не по порядку. Асинхронный worker может продолжить работу после root span. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезным, но снижают силу вывода. Их нужно записать рядом с наблюдением.

\n

Карта интервалов не заменяет нагрузочный тест. Она не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Она также не задаёт SLA. HTTP-стандарт описывает семантику запроса и ответа, а не конкретный бюджет latency. Для этих вопросов нужны собственные измерения и критерии.

\n

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

\n

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

\n

Проверка достаточна для технического вывода, если другой инженер получает тот же вход и без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу нагрузки, отличить названную задержку от unknown и воспроизвести stop на неполном trace или несопоставимой нагрузке. Сравнение до и после допустимо, если записи сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а новый результат не маскирует ошибку ростом таймаутов или потерей сигнала.

\n

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

\n

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

\n" + "title": "Низкий CPU, длинный запрос: как найти границу задержки", + "excerpt": "CPU почти свободен, а пользователь ждёт ответ десять секунд. Разбираем trace, очередь, сопоставимую нагрузку и границу, за которой нельзя объявлять причину.", + "contentHtml": "

Симптом выглядит так: пользователь ждёт ответ десять секунд, а график CPU держится на двадцати процентах. В такой ситуации легко объявить виновным запрос к базе, добавить поток или увеличить timeout. Но низкий CPU не противоречит длинному ответу: запрос мог ждать очередь, соединение из пула, блокировку, диск или внешний сервис. Неизвестное ожидание не превращается в причину оттого, что оно попало в один end-to-end интервал.

\n

Разберём учебный полевой сценарий: один HTTP-запрос, его trace и фиксированная контрольная нагрузка. Цель не в том, чтобы угадать узкое место по самому длинному отрезку. Нужно отделить наблюдение от гипотезы, назвать недостающий сигнал и только потом выбрать небольшое изменение. Такой порядок оставляет после расследования не впечатление, а запись, которую другой инженер сможет проверить.

\n

Сначала зафиксировать симптом

\n

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

\n

CPU — метрика ресурса, а не таймер конкретного запроса. Процессор может простаивать, пока поток ждёт свободное соединение или ответ удалённого сервиса. Даже высокий CPU не доказывает причину: горячий участок мог работать параллельно с ожиданием, а агрегированная метрика могла скрыть один перегруженный worker. В начале записи отделите факт от предположения: «root длился 10 000 мс, CPU процесса был около 20%» — факт; «запрос тормозит из-за базы» — пока гипотеза.

\n

Если trace отсутствует или связывает только часть пути, сила вывода ограничена. Запишите это сразу. Попытка восстановить дерево по времени логов полезна как поиск следующего сигнала, но не заменяет корректную parent/child-связь.

\n

Как trace раскладывает время

\n

Trace — это путь одной операции через систему. Span — отдельная единица работы внутри этого пути. Корневой span обычно описывает всю операцию, а дочерние span-ы — её подоперации. У каждого интервала должны быть начало, конец, идентификатор и связь с родителем. Эта модель помогает спросить: «какой участок наблюдается?» Она не отвечает автоматически на вопрос: «что произойдёт после изменения?»

\n

End-to-end время включает и работу, и ожидание. Очередь допуска, выдача соединения, блокировка строки, DNS, TLS, чтение диска, retry и ожидание ответа партнёра могут занимать время при низком CPU. Название span должно соответствовать записанной операции. db-call не означает всё время до базы, если ожидание соединения записано за его пределами или не записано вовсе.

\n

Следите за двумя границами. Первая — временная: root от начала принятия операции до отправки результата. Вторая — экспериментальная: одинаковые cohort, число запросов, concurrency, входные данные, версия приложения и правило отбора trace. Без второй границы короткий ответ после изменения может быть следствием меньшей нагрузки, попадания в кэш или другого набора данных.

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

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

\n
Как превратить наблюдение в проверяемое действие
СимптомВозможное объяснениеПроверкаДействие
CPU низкий, root медленныйЗапрос ждёт очередь, пул соединений или внешний ответСопоставить root с дочерними span-ами и метриками ожиданияНазвать покрытый участок; неизвестный остаток оставить unknown
Самый длинный span совпадает с пиком latencySpan включает retry или ожидание внутри зависимостиПроверить статус, число попыток, вложенные интервалы и границу сервисаНе объявлять span причиной без сигнала, который отделяет работу от ожидания
После изменения ответ корочеИзменились cohort, concurrency, кэш или форма данныхСверить контрольные поля и распределение наблюденийОтменить вывод и повторить с одной нагрузочной границей
Дочерний span ссылается на отсутствующего родителяПотерян экспорт, сломана передача контекста или неверен IDПроверить полный экспорт, уникальность ID и заголовок traceparentОстановить причинный вывод и починить наблюдаемость
Есть одна удачная записьНет пары и распределения, поэтому случай может быть выбросомПовторить сценарий и сравнить одинаковые квантили либо все наблюденияНазывать это наблюдением, а не эффектом изменения
\n

Учебная запись и отрицательный путь

\n

Ниже приведена полностью синтетическая запись. Числа условны, код не обращается к сети, базе, часам или профайлеру. Он проверяет только связность дерева и корректность локальных интервалов. Название observation-ready означает «запись можно читать», а не «причина найдена».

\n
const trace = {\n  root: { id: 'root-01', start: 0, end: 1000 },\n  spans: [\n    { id: 'queue-01', parent: 'root-01', name: 'admission-queue', start: 40, end: 560 },\n    { id: 'db-01', parent: 'root-01', name: 'db-call', start: 570, end: 720 },\n    { id: 'catalog-01', parent: 'root-01', name: 'catalog-call', start: 730, end: 930 }\n  ],\n  load: { cohort: 'load-a', requests: 12, concurrency: 3, shape: 'read-shape-a' }\n};\n\nfunction inspectTrace(input) {\n  const ids = new Set(input.spans.map((span) => span.id));\n  const connected = input.spans.every((span) =>\n    span.parent === input.root.id || ids.has(span.parent)\n  );\n  const ordered = input.spans.every((span) =>\n    Number.isFinite(span.start) &&\n    Number.isFinite(span.end) &&\n    span.end >= span.start\n  );\n\n  if (!connected) return { status: 'stop-incomplete-trace' };\n  if (!ordered) return { status: 'stop-invalid-interval' };\n  return { status: 'observation-ready', claim: 'not-measured' };\n}\n\nconsole.log(inspectTrace(trace));
\n

Для этой записи root длится 1000 условных единиц, очередь — 520, база — 150, каталог — 200. Сумма дочерних интервалов равна 870, но оставшиеся 130 нельзя назвать сетью: интервалы могли перекрываться, а часть работы могла не иметь span. Корректная запись результата звучит так: «130 единиц не покрыты названными интервалами; нужен отдельный сигнал».

\n

Теперь замените parent: 'root-01' на parent: 'missing-01'. Функция вернёт stop-incomplete-trace. Если конец интервала меньше начала, появится stop-invalid-interval. Эти отрицательные пути важны: без них система может продолжить рассуждение по красивому, но повреждённому дереву. Неполный trace — причина продолжить сбор данных, а не разрешение выбрать удобного виновника.

\n

Не складывать интервалы механически

\n

Дочерние операции могут идти последовательно или параллельно. В последовательном пути суммарная длительность часто близка к root за вычетом неразмеченных участков. В параллельном пути сумма дочерних span-ов может быть больше root. Поэтому сложение всех длительностей не даёт автоматически критический путь.

\n

Для критического пути нужно увидеть порядок зависимостей и момент, когда root действительно мог завершиться. Если два вызова стартовали рядом и один ждал другой только на стадии сборки ответа, их интервалы нельзя трактовать как две последовательные секунды. Полезнее построить waterfall: начало и конец каждого span-а, parent, зависимость и участок ожидания. Если система не экспортирует такие данные, вывод ограничивается известными границами.

\n

Также не смешивайте клиентскую и серверную задержку. Время до отправки HTTP-запроса, очередь на прокси, обработка в приложении и чтение ответа — разные участки. W3C Trace Context помогает передать идентификатор между границами, но сам заголовок не создаёт отсутствующие span-ы и не гарантирует, что каждый посредник сохранит запись. Для каждого разрыва нужен отдельный способ проверки.

\n

Порядок безопасного эксперимента

\n
  1. Опишите исходный симптом: маршрут, статус, длительность, размер ответа и временное окно.
  2. Сохраните trace и связанные логи до изменения. Отметьте root, дочерние операции, пропуски, retry и неизвестные интервалы.
  3. Проверьте связность ID, порядок времени и единицы измерения. Не дорисовывайте родителя по одному совпадению timestamp.
  4. Зафиксируйте control boundary: cohort, число запросов, concurrency, форму входа, кэш и версию приложения.
  5. Сформулируйте две гипотезы, например «ждём пул» и «ждём внешний ответ». Для каждой запишите наблюдение, которое её опровергнет.
  6. Выберите самый маленький эксперимент: отдельная метрика ожидания, временный лог границы пула, повторяемый запрос или контрольный прогон без одного вызова.
  7. Проверьте отрицательный случай: отсутствующий parent, пропущенный span и несопоставимая нагрузка должны останавливать вывод.
  8. Измените один фактор и повторите тот же сценарий. Сохраняйте записи до и после рядом с одинаковыми полями.
  9. Сравните не одну удачную запись, а распределение и соседние сигналы: ошибки, timeout, очередь, throughput и потребление ресурса.
  10. Запишите результат с границей: что измерено, что изменилось, в каких условиях и какой вопрос остался открытым.
\n

Выбор действия и критерий результата

\n

Если отдельный сигнал подтвердил ожидание в пуле, действие может быть локальным: проверить размер пула, время выдачи соединения и конкуренцию. Увеличивать пул без измерения опасно: можно перенести очередь в базу и поднять число одновременных запросов. Если подтверждён внешний вызов, сравните timeout, retry и кэширование, но не принимайте рост timeout за улучшение — пользователь может ждать дольше.

\n

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

\n

Результат эксперимента должен включать baseline, контрольные поля, число наблюдений, метрику сравнения и побочный эффект. Фраза «стало быстрее» слишком коротка для воспроизводимого решения. Точнее: «на одинаковом наборе из 12 запросов при concurrency 3 медиана root изменилась с X до Y; число ошибок и таймаутов не выросло; p95 не проверялся». Если X и Y не измерены, так и напишите.

\n

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

\n

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

\n

Sampling может исключить нужный trace или span. Collector может потерять событие, а асинхронный worker — продолжить работу после завершения root. Повторные попытки создают несколько похожих операций. Часы разных узлов могут расходиться, поэтому абсолютное положение соседних интервалов требует осторожности. Эти условия не запрещают анализ, но снижают силу вывода и должны попасть в запись расследования.

\n

Нельзя переносить условные числа из примера в SLA. RFC 9110 описывает семантику HTTP-запросов и ответов, но не устанавливает бюджет времени конкретного приложения. Бюджет latency, допустимый процент ошибок и окно сравнения задаёт сама система вместе с её требованиями. Если таких требований нет, сначала согласуйте критерий, иначе эксперимент не имеет точки принятия решения.

\n

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

\n

Расследование можно передавать следующему инженеру, если он получает исходный симптом, trace, контрольную границу и список пропусков. Он должен найти root, проверить parent/child-связи, отличить названный интервал от unknown, повторить отрицательный путь и понять, какое наблюдение подтвердит или опровергнет следующую гипотезу.

\n

Измеренный эффект допустимо объявлять только при сопоставимых записях до и после, одном изменённом факторе, одинаковом способе измерения и проверенных ошибках и таймаутах. Иначе итог формулируется скромнее: «найден участок для дальнейшей проверки» или «данных недостаточно». Такая граница сохраняет время команды и не маскирует отсутствие причинного доказательства.

\n

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

\n" }