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Безопасный переход — это не кнопка и не линейный список задач. Это последовательность ворот. Сначала система должна иметь полный для выбранного случая инвентарь. Затем она должна сравнивать control и candidate на одной границе. После этого нужно отдельно описать состояние данных и способ его восстановления. В конце нужен именованный триггер rollback, владелец решения и оба состояния возврата.
\nВорота проверяют структуру решения. Они не подтверждают, что production уже работает хорошо. Положительный результат означает только: карточка перехода достаточно полна для следующего инженерного шага. Он не переключает маршрут, не переносит записи и не обещает отсутствие ошибок.
\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-семантику можно восстановить. Это особенно важно при расширении схемы, смене идентификатора или переносе владельца записи.
Четвёртые ворота делают rollback решаемым. Назовите trigger, условие, decision owner, traffic return и data return. Слово «аномалия» слишком широко. Оно не говорит, какой сигнал остановит переход. «Ошибки выросли» тоже недостаточно, если не указаны окно, источник и правило сравнения. Именованный trigger не запускает откат сам. Он делает вопрос воспроизводимым для того, кто принимает решение.
\nНиже — учебный JavaScript-пример. Он проверяет только фиксированный объект в памяти. Он не читает балансировщик, базу данных, метрики или Kubernetes API. Его задача — показать отрицательную ветку: candidate существует, но контрольная граница не задана.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 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 |
Представьте три неполных карточки. В первой нет dependency. Правильный результат — stop-missing-inventory; обсуждать проценты трафика рано. Во второй control и candidate указывают разные маршруты. Результат — stop-unbounded-traffic-slice; числа 90 и 10 не становятся доказательством. В третьей есть source, target и copy, но нет restore state. Результат — stop-irreversible-data-state; возврат маршрута не объявляют полным rollback.
Есть и четвёртая ошибка: trigger назван, но не задано условие. «Rollback по решению владельца» не отвечает на вопрос, какое наблюдение открывает решение. В таком случае возвращается stop-unnamed-rollback-trigger. Система не должна угадывать порог, подставлять последний dashboard или считать любой timeout достаточным. У разных переходов разные сигналы и разные допустимые последствия.
Порядок проверки намеренно короткий. Первая ошибка останавливает оценку. Это не скрывает остальные дефекты. Это задаёт ближайшую проверяемую работу. Если перечислить сразу десять проблем, команда может исправить вторую и пропустить первую. Если назвать только статус «не готово», следующий исполнитель снова будет восстанавливать контекст из разговора.
\nУ слова rollback нет общего смысла для всех слоёв. В документации Kubernetes откат Deployment относится к его Pod template. Это полезная граница: возврат версии workload не означает возврат записей в хранилище и не отменяет побочные эффекты приложения. Поэтому в карточке перехода traffic return и data return должны быть отдельными полями.
\nУ базы данных граница может быть другой. PostgreSQL описывает ROLLBACK как отмену изменений текущей транзакции. Это не равно восстановлению данных, уже записанных в другой транзакции, внешней очереди или стороннем сервисе. Нельзя перенести семантику одной транзакции на весь процесс миграции. Сначала назовите объект и границу действия.
Эта модель не измеряет задержку, error rate, нагрузку, стоимость простоя или долю пользователей. Она не проверяет корректность выбранного owner. Она не знает, что произойдёт при конкурирующих записях, повторной доставке сообщения или частичной недоступности хранилища. Именованный trigger тоже может быть плохим. Механизм лишь не даёт скрыть его отсутствие.
\nУчебный код не создаёт traffic slice, не запускает SQL, не меняет Deployment и не выполняет восстановление. Его можно использовать для проверки формы карточки и отрицательных веток. Перед реальным переходом нужны инвентарь конкретной системы, rehearsal, наблюдение, права на действие и отдельно описанная процедура восстановления. Ни один положительный результат этой статьи не заменяет их.
\nМеханизм готов к следующему review, если независимый читатель получает одинаковый результат из одной карточки: выбранный объект назван; owner, reader, writer и dependency связаны; control и candidate имеют общую границу; observation window указан; data state содержит reconciliation и restore; rollback имеет trigger, условие, владельца и два return state; каждый неполный вариант выдаёт именованный stop. Положительный результат остаётся hand-off. В нём нет утверждения о выполненном rollout, исправленных данных или достигнутом production-эффекте.
\nПосле переключения на новую версию запросы начинают возвращаться с ошибкой. Команда нажимает rollback, старый маршрут снова отвечает, но часть записей уже прошла через новую схему. Трафик вернулся. Данные — нет. Цена ошибки — не только простой: оператор теряет границу между отменённым изменением и тем, что осталось в хранилище, очереди или внешнем сервисе.
\nПричина обычно не в отсутствии кнопки отката. Миграцию описали одним статусом — «готово», «можно переключать» или «rollback есть». Такой статус не отвечает на четыре разных вопроса: что именно переезжает, с чем сравнивают новый путь, как восстанавливают данные и какое наблюдение открывает решение о возврате. Разделим эти вопросы и проверим их на одном фиксированном примере.
\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-семантику можно вернуть. Особенно опасны смена идентификатора, преобразование значения и запись в несколько систем.
Четвёртая граница — rollback decision. Нужны trigger, условие, decision owner, traffic return и data return. Слово «аномалия» слишком широко: оно не говорит, какой сигнал остановит переход. Именованный trigger тоже ничего не откатывает сам. Он делает решение воспроизводимым для конкретного владельца.
\nУ каждой границы должен быть наблюдаемый вход и ограниченный вывод. Inventory даёт имена связям, но не подтверждает корректность архитектуры. Traffic описывает сравнение, но не гарантирует хороший результат. Data state показывает возможность reconciliation и restore, но не заменяет rehearsal. Rollback record фиксирует решение, но не исполняет его.
\nПолезно различать два результата. stop означает, что не хватает конкретного факта и следующий шаг известен. ready-for-review означает только полноту записи для следующего инженерного просмотра. Это не разрешение на cutover, не доказательство успешного production-запуска и не заявление о восстановленных данных.
Такое разделение убирает распространённую ошибку: считать зелёным весь переход, если зелёным стал только слой оркестрации. Система может вернуть старую версию приложения и одновременно оставить новые записи, отправленные сообщения или необратимое преобразование в базе.
\nДокументация Kubernetes описывает rollback Deployment как возврат к предыдущей ревизии его конфигурации. В этой модели возвращается Pod template: например, образ контейнера и связанные поля шаблона. Deployment controller создаёт или масштабирует ReplicaSet, а история ревизий хранится в ReplicaSet. Это граница workload, а не обещание вернуть состояние приложения.
\nИз этого следует практическое правило: kubectl rollout undo может быть корректным действием для маршрута или набора Pod, но он не отменяет записи, уже зафиксированные приложением, и не отзывает побочный вызов во внешний сервис. Если старые ReplicaSet удалены из-за ограничения истории, выбранная ревизия может стать недоступной для такого возврата. Это ещё одна причина называть версию и её сохранность до начала перехода.
В PostgreSQL граница уже: ROLLBACK отменяет текущую транзакцию и отбрасывает изменения, сделанные этой транзакцией. Команда не распространяет это свойство на другую транзакцию, очередь, HTTP-вызов или отдельный процесс миграции. Поэтому транзакционный rollback и восстановление данных после частичного перехода нельзя записывать одним полем.
Ниже — учебный JavaScript-пример. Он работает только с объектом в памяти и проверяет полноту записи. В нём нет доступа к балансировщику, Kubernetes API, базе или метрикам. Все значения проектные: их нужно заменить фактами конкретной системы перед применением.
\nconst 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. Такой контрпример полезнее сообщения «миграция не готова»: он показывает, какое поле и почему нужно восстановить.
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Старый маршрут отвечает, а записи расходятся | 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-проверку |
Таблица не заменяет метрики. Она определяет, к какой метрике или записи нужно обратиться первой. Например, рост ошибок candidate сравнивают с control в том же окне и на той же границе. Одного абсолютного числа без baseline недостаточно: оно может отражать общий сбой зависимости, а не эффект новой версии.
\nМодель подходит для миграции маршрута, схемы, владельца данных или версии workload, когда можно явно назвать объект и его границы. Она не выбирает порог error rate, не доказывает, что 10% трафика статистически достаточны, и не отвечает за согласованность между несколькими хранилищами. Эти решения зависят от нагрузки, критичности операции, требований к задержке и допустимого риска.
\nУчебный код не создаёт traffic slice, не выполняет SQL, не вызывает kubectl и не восстанавливает snapshot. Он проверяет только форму записи и порядок fail-closed веток. Для реального перехода нужны права на действие, резервная копия с подтверждённым восстановлением, наблюдение за зависимостями и план для сообщений или побочных вызовов, которые нельзя отменить транзакцией.
Есть и случаи, где rollback невозможен по определению: отправлено письмо, списана внешняя комиссия, опубликовано событие в системе без компенсационной операции. Тогда data return должен описывать не «вернуть назад», а compensating action, идемпотентность и контроль остаточного эффекта. Если компенсации нет, переход нельзя объявлять обратимым; нужно уменьшать blast radius до начала записи.
\nЗапись готова к инженерному review, когда независимый читатель находит в ней один объект перехода, подтверждённые связи inventory, общую traffic boundary, окно наблюдения, source и target данных, способ reconciliation, проверенный restore, измеримый trigger, decision owner и оба return state. Положительная ветка выдаёт только ready-for-review. Она не утверждает, что переключение состоялось, данные исправны или production-эффект достигнут.
После переключения на новую версию часть заказов читает новые поля, а часть записывает старые. HTTP-ответы остаются успешными. Ошибка проявляется позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не возвращает уже записанные данные. Команда видит зелёный deploy, но не может быстро ответить, что именно откатывать.
\nЦена ошибки — не только простой. Оператор повторяет операции, разработчик сверяет несовместимые логи, а ручное исправление может создать дубликаты. Чем дольше работают две схемы, тем больше записей пересекают границу. Поэтому миграцию нельзя сводить к копированию и последующему cutover.
\nТезис простой: безопасный переход состоит из совместимых состояний, ограниченного среза трафика и заранее названного пути возврата. Каждый этап должен отвечать на четыре вопроса: что меняется, кто владеет состоянием, как проверяется переход и что вернётся при отказе. Если ответа нет, этап останавливается.
\nРассмотрим учебный пример. Сервис заказов хранит поле status, а новая версия хочет использовать state. Нельзя сразу удалить старое поле. Сначала новая схема принимает оба имени, затем приложение пишет оба значения, потом команда сверяет записи и переводит чтение на новое поле. Только после этого старый контракт можно убрать.
Такой порядок разделяет четыре разных изменения. Схема должна принять новый формат. Писатель должен создать согласованные значения. Читатель должен уметь сравнить старое и новое представление. Маршрутизатор должен направить ограниченный поток на новый путь. Если один шаг смешать с другим, откат приложения не отменит изменение данных.
\nНачните с одного маршрута, а не со всей системы. Запишите его владельца, читателя, писателя, запись и внешние зависимости. Для примера это GET /orders/:id, таблица orders, обработчик записи и индекс, которым пользуется отчёт.
Связи важнее списка файлов. Если известен маршрут, но неизвестен писатель, нельзя оценить совместимость записи. Если известен писатель, но нет читателя отчёта, нельзя определить, где появится расхождение. Пустое звено — это не мелкая недостача документа. Это причина остановить переход до проверки.
\nКопия отвечает только на вопрос «создан ли второй набор». Она не отвечает, совпадают ли ключи, как обрабатываются новые записи и куда вернётся запись при отказе. Поэтому карточка перехода должна хранить источник, назначение, способ сверки и состояние восстановления.
\nДля учебного сценария достаточно такой модели:
\nconst 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, запросы и окно | Создать сопоставимую контрольную сторону |
Число «10% трафика» само по себе ничего не доказывает. Нужна контрольная сторона с тем же типом запроса, сопоставимым окном и одинаковыми правилами подсчёта ошибок. В учебной модели orders-read-v1 — control, а orders-read-v2 — candidate. Это имена границ, а не рекомендация направлять ровно десять процентов реального трафика.
Сравнивайте не только HTTP-коды. Проверьте долю ошибок контракта, расхождение значений, задержку и долю повторных запросов. Порог зависит от сервиса и его SLO. Если порог не определён, результат «ошибок не заметили» нельзя использовать как разрешение расширить срез.
\nВозврат версии приложения возвращает код. Возврат маршрута возвращает поток запросов. Восстановление данных возвращает способ обработки записей. Эти действия могут иметь разные триггеры и разных владельцев. Фраза «откатим релиз» не описывает ни одного из них.
\nУкажите условие остановки до начала среза. Например: «в окне наблюдения появился mismatch контракта для нормализованного значения». Затем укажите, кто принимает решение, куда возвращается чтение и какой писатель принимает новые данные после возврата. Если запись уже прошла только через новую схему, одного переключения маршрута недостаточно.
\nОфициальная документация Kubernetes прямо ограничивает смысл rollback Deployment: при возврате ревизии восстанавливается Pod template. Это полезное различие. Возврат контейнера не отменяет SQL-изменения, сообщения в очереди или внешний API-контракт. Такие состояния нужно проектировать отдельно.
\nСледующая функция демонстрирует fail-closed проверку. Она возвращает причину остановки, если отсутствует контрольная сторона, обратимое состояние данных или условие rollback. Пример учебный: он не вызывает внешние системы и не подтверждает готовность реального перехода.
\nfunction 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Схема не выбирает способ репликации и не задаёт универсальный процент трафика. Она не решает конфликты конкурентной записи, задержку репликации, изменение индексов или восстановление внешних потребителей. PostgreSQL предупреждает, что логическая репликация может остановиться на конфликте ограничений, а некоторые отсутствующие строки при обновлении или удалении пропускаются. Значит, одну сверку количества строк нельзя считать доказательством эквивалентности.
\nСхема также не заменяет rehearsal. Учебный объект проверяет полноту описания, но не проверяет реальную выборку. Для production нужны контрольные запросы, журнал изменений, лимит времени, доступ к процедуре восстановления и ответственный, который может остановить переход. Если хотя бы один из этих элементов не проверен, критерий готовности не выполнен.
\nПереход готов к отдельному решению владельца, когда для выбранного маршрута можно воспроизвести одну запись в старом и новом представлении, показать правило сверки, назвать control и candidate, а также выполнить отрицательный сценарий с точным trigger. Отдельно должно быть понятно, как новые записи вернутся к источнику. Если команда может только вернуть контейнер, но не объяснить судьбу данных, миграция не готова.
\nПосле переключения на новую версию часть заказов читает новое поле, а часть продолжает писать старое. HTTP-ответы остаются успешными, поэтому сбой обнаруживается позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не отменяет уже записанные значения. Команда видит зелёный deploy, но не может быстро ответить, что именно возвращать.
\nУ такой ошибки несколько состояний. Код можно вернуть на предыдущую ревизию, запросы можно отправить на прежний маршрут, но данные уже могли пройти через новый преобразователь. Если эти действия не разделены до начала работ, откат превращается в импровизацию: кто-то исправляет схему, кто-то повторяет операции, а журнал показывает несовместимые версии.
\nНиже — рабочая модель для изменения контракта заказа с status на state. Это не инструкция для конкретной базы или балансировщика. Её цель — заставить миграцию отвечать на четыре вопроса: какой участок меняется, как доказать совместимость, где остановить поток и как восстановить запись после отказа.
Безопасный переход состоит не из одного cutover, а из состояний, которые можно наблюдать и покинуть. Сначала старый контракт остаётся рабочим. Затем новая схема принимает оба представления. После этого писатель создаёт согласованные значения, а сверка проверяет уже существующие записи. Только потом новый читатель получает ограниченный поток.
\nПорядок имеет значение. Если удалить status одновременно с выпуском нового читателя, неизвестно, что именно сломалось: схема, сериализация, выборка или маршрутизация. Если сначала включить двойную запись, но не определить, какое значение является источником истины, команда накопит расхождения, которые позднее будет трудно отличить от корректных преобразований.
Начните с одного маршрута, таблицы или события, а не с формулировки «перенести систему». Для GET /orders/:id запишите владельца, читателя, писателя, источник данных, индекс, кэш, очередь и внешних потребителей. Для каждого звена добавьте версию контракта и способ проверить результат.
Полезный инвентарь отвечает на вопрос «кто ещё может записать старую форму?». Один забытый batch-job способен продолжать отправлять status после переключения чтения на state. Один отчёт с собственным SQL может видеть другую картину, даже если основной API выглядит исправным. Неизвестный писатель — самостоятельный стоп-сигнал, а не поле для предположения.
Зафиксируйте границу операции. Например, candidate обслуживает только чтение заказа через один API-маршрут, а фоновая выгрузка остаётся на control. Тогда результат среза относится к конкретному маршруту и набору запросов, а не ко всей платформе. Если границы различаются, их сравнивают отдельно.
\nДля изменения имени поля примените expand/contract-последовательность. На этапе expand добавьте state, не удаляя status, и разрешите чтение обеих форм. Затем выберите источник истины: например, новое значение вычисляется из старого, пока двойная запись не станет проверяемой. При каждой записи сохраняйте правило преобразования, а не только итоговое значение.
На этапе двойной записи обработчик должен быть идемпотентным: повтор одной операции не создаёт новую сущность и не меняет результат непредсказуемо. Это требование зависит от ключа и бизнес-операции; универсальная функция «перезаписать всё» его не обеспечивает. Отдельно проверьте null, неизвестное значение, смену регистра, часовой пояс и округление, если они участвуют в преобразовании.
\nПосле сверки новый читатель может стать primary, но старый писатель ещё должен оставаться совместимым на время окна наблюдения. Contract закрывается последним: удаление поля допустимо только после поиска читателей, писателей, миграционных скриптов и восстановительных процедур. Откат приложения в этот момент уже не вернёт удалённую колонку.
\nСравнить количество строк недостаточно. Две таблицы могут иметь одинаковый размер, но разные ключи, пропущенные значения или разные нормализованные статусы. Для выборки задайте стабильный ключ, момент среза и правило сравнения. Результат должен содержать количество проверенных записей, число расхождений, тип расхождения и ссылку на повторяемый запрос.
\nУчебный SQL ниже показывает форму проверки, а не готовую команду для вашей схемы. Он отдельно считает отсутствующий ключ и сравнивает значения null-safe. На реальном стенде добавьте фильтр по согласованному срезу, лимит нагрузки, обработку удаления и защиту от чтения незавершённой записи.
\nWITH 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 скрывает потерю информации, нулевой результат всё равно не доказывает эквивалентность. Для критичных полей полезны выборочная проверка исходных значений и обратное преобразование.
Процент трафика сам по себе не является доказательством безопасности. Нужны две стороны: control на старом чтении и candidate на новом чтении, одна граница маршрута, сопоставимые ключи и одно окно наблюдения. Не смешивайте в одном числе ошибки API, расхождения данных, задержку и повторные запросы: у каждого сигнала должен быть владелец и порог.
\nДо включения candidate запишите базовую линию control. В течение окна фиксируйте объём запросов, долю ошибок, p95 или другой согласованный показатель задержки, mismatches и обращения к fallback. Порог не следует выдумывать в статье: его определяет SLO и риск конкретной операции. Важно, чтобы команда заранее знала, какое событие останавливает расширение.
\n| Симптом | Вероятная граница | Проверка | Решение |
|---|---|---|---|
| Новый читатель получает пустое поле | Схема или двойная запись | Сопоставить ключи и момент первой записи в обеих формах | Остановить candidate; восстановить запись и повторить сверку |
| Старый и новый отчёт расходятся | Преобразование или собственный потребитель | Сравнить одну запись по исходному ключу и правилам нормализации | Исправить контракт отчёта до расширения потока |
| Ошибок API нет, но растут mismatches | Семантика данных | Разложить расхождения на null, ключ, значение и время записи | Не считать HTTP 2xx разрешением на cutover |
| После возврата маршрута появляются новые расхождения | Write state | Проверить, какой писатель принимал данные в окне | Вернуть совместимый писатель или применить проверенное преобразование |
| Нельзя определить момент остановки | Решение и наблюдаемость | Найти порог, окно, owner и команду остановки | Оставить control и не расширять candidate |
Возврат кода меняет исполняемую версию. Возврат маршрута меняет, куда идут запросы. Восстановление данных меняет, какой писатель и какой формат принимают новые записи. Эти действия могут выполняться в разном порядке. Поэтому runbook должен содержать три отдельные команды, три проверки результата и одного ответственного за решение.
\nДокументация Kubernetes уточняет границу rollback Deployment: ревизия создаётся при изменении Pod template, а возврат к предыдущей ревизии откатывает именно эту часть Deployment. Это возвращает образ, параметры и другие элементы шаблона Pod, но не SQL-транзакции, сообщения очереди, записи во внешнем сервисе или уже опубликованный контракт. Их состояние описывается отдельными шагами.
\nЕсли data state необратим, откат должен быть не «вернуть старое», а заранее проверенный способ продолжить работу: dual-read, обратное преобразование, остановка записи или восстановление из согласованной копии. Выбор зависит от потерь и бизнес-операции. До среза выполните его на тестовом наборе и зафиксируйте, как обнаруживаются частичные результаты.
\nСоберите перед запуском одну карточку. В ней должны быть route boundary, owner, source, target, версия преобразования, запрос сверки, контрольные метрики, окно, trigger остановки и действия для кода, маршрута и данных. Пример ниже намеренно возвращает stop, если обязательное поле отсутствует. Положительный ответ говорит только о полноте карточки, а не о готовности production-среды.
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Эта схема подходит для постепенного изменения совместимого контракта, но не является универсальным планом восстановления. Она не решает конкурентные записи, задержку репликации, несовместимую семантику удаления, смену ключа, перестроение индексов, миграцию файлов или восстановление внешнего сервиса. Для каждой такой границы нужен отдельный план данных и тест отказа.
\nPostgreSQL описывает конкретные ограничения логической репликации: конфликт ограничения может остановить репликацию до ручного разрешения, а отсутствующая строка при UPDATE или DELETE может быть пропущена. Поэтому одинаковое число строк не доказывает, что два состояния равны. Проверьте версию PostgreSQL, replica identity, права, фильтры публикации и статистику конфликтов на целевой конфигурации.
Учебный SQL и JavaScript не подключаются к базе, не управляют трафиком и не подтверждают безопасность реального cutover. Они показывают форму проверки. Перед production-запуском нужны rehearsal на близком объёме данных, резервный план, доступ к остановке потока, журнал результата и человек, который имеет право отменить расширение.
\nПереход можно выносить на решение владельца, когда для одной границы воспроизводятся исходная запись, новая запись и правило их сравнения; control и candidate измеряются в сопоставимом окне; а отказ приводит к заранее названным действиям для кода, маршрута и данных. Отдельно должны быть проверены неизвестный писатель, частичная запись и конфликт при восстановлении.
\nЕсли команда способна вернуть только контейнер, но не объясняет судьбу записей, это не rollback миграции. Если есть нулевая сверка, но неизвестно, кто писал в окно, это не доказательство совместимости. Готовность — это не зелёный deploy, а повторяемая проверка с понятным стоп-триггером и обратимым состоянием.
\nUPDATE и DELETE. Это ограничение нужно учитывать при выборе ключа сверки.Запрос проходит через PHP, затем попадает в JavaScript и заканчивается обработкой на D. Пользователь видит ошибку без понятной причины, а оператор не может связать запись D с исходным запросом. Иногда все три части возвращают похожий JSON, но одна сторона считает ошибку исключением, другая — обычным значением, а третья записывает время в другой шкале. Внешне система работает. Внутри она уже потеряла общий смысл.
\nЦена такой ошибки выше, чем один неудачный запрос. Команда может повторить несовместимый ответ, принять его за временный сбой и включить повторную попытку там, где операция уже выполнена. Можно получить двойное списание, зависшую задачу или неверный отчёт. Похожая форма данных не защищает от разных правил обработки.
\nТри runtime можно соединить только через узкий, именованный контракт. Он должен назвать операцию, форму успешного значения, форму ошибки и основание времени. Каждый адаптер обязан сохранить эти поля без скрытого приведения. Если поле нельзя сопоставить, система должна остановиться и назвать причину. Молчаливое «примерно подходит» опаснее явного отказа.
\nВ этом материале проверяется модель границы. Учебный пример хранит запись в памяти JavaScript и читает её обычной функцией. Он не запускает PHP, JavaScript как отдельный процесс или D. Он не обращается к сети, диску, часам, телеметрии и сервисам. Поэтому его результат говорит только о внутренней сопоставимости записи. Это ограничение входит в смысл примера.
\nСначала назовите операцию. Строка fixed-order-decision лучше, чем общий «обработчик заказа»: у неё есть конкретная граница. Затем задайте value tag. В примере это order-ready. Число 4200 получает единицу и валюту. Без tag и единицы потребитель может принять копейки за рубли или число лимита за сумму.
Ошибка получает named envelope. В нём явно присутствуют semantics, code и retry. Значение code: null означает отсутствие кода внутри известного envelope. Отсутствующий сам envelope означает другую проблему. Эти два случая нельзя сливать в одну пустую строку.
Время в изолированной модели задаётся ordered fixed logical ticks. Пара 100..108 показывает порядок и интервал из восьми условных шагов. Это не миллисекунды, не latency и не SLA. Если нужен production-замер, он требует отдельного источника времени, политики измерения и проверки среды.
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 не скрывает преобразование.
Одинаковый текст ошибки не задаёт одинаковое действие. PHP может вернуть envelope, JavaScript — выбросить значение, а D — записать код в отдельное поле. Потребитель, который проверяет только сообщение, потеряет режим завершения. Сравнивайте не текст, а семантику: кто владеет ошибкой, можно ли повторить операцию и какие данные сохраняются.
\nОдинаковое число времени тоже ничего не гарантирует. Один адаптер может передать логические шаги, другой — epoch seconds. Числа совпадут случайно, а вывод окажется ложным. Поэтому basis должна быть полем контракта и каждого адаптера. Пропущенный closed должен закрывать проверку, а не заменяться текущим временем.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ответы похожи, но 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 | Добавить поле в новый контракт; не восстанавливать связь по времени |
Проверка должна отказываться от удобного вывода. Возьмём запись, где у одного адаптера errorSemantics: 'thrown-value', а у контракта остаётся named-envelope. Такой набор нельзя объявить совместимым. Функция возвращает stop-incomparable-adapter и оставляет владельцу конкретное действие: выровнять семантику.
Другой случай: time.basis равен wall-clock, а closed отсутствует. Здесь нельзя написать «обработка заняла неизвестное время» и нельзя вычислить значение из текущих часов. Проверка должна вернуть stop-undetermined-time-boundary. Она не спорит о точности. Она фиксирует отсутствие основания для сравнения.
Третий случай — coercion. Если адаптер превратил строку в число, округлил сумму или заменил пустое значение значением по умолчанию, результат уже не exact mapping. Приведение может быть правильным в конкретной программе, но оно требует правила, единицы и теста. До этого момента оно скрывает смысл и закрывает hand-off.
\namountMinor и currency.Допустимая формулировка узкая: «фиксированная запись соответствует названным правилам и может перейти на synthetic review». Нельзя писать «PHP, JavaScript и D совместимы», «адаптер работает», «интеграция подтверждена» или «latency равна восьми». У модели нет runtime, транспорта, хоста, зависимостей, прав, пользовательских данных и production-метрик.
\nЭто не бюрократическая оговорка. Явная граница защищает решение от расширения смысла при копировании. Читатель видит, какой факт проверен, а какой ещё требует отдельного эксперимента. Если понадобится связать PHP-запрос и запись D, добавьте correlation id и проверьте его на реальном пути. Не выводите связь из одинакового времени, порядка строк или похожего JSON.
\nМодель не описывает ABI, сериализацию, nullable policy, иерархию классов, stack unwinding, сборку мусора, планировщик, retry конкретного клиента или схему регистрации сервисов. Эти свойства нельзя спрятать в поле details. Если свойство влияет на решение, назовите его отдельным полем и задайте проверку. Если назвать его нельзя, остановите границу.
Модель также не заменяет контракт домена. Tag order-ready говорит о форме значения, но не доказывает, что бизнес действительно разрешает выдавать заказ. Доменный смысл проверяет владелец операции. Техническая проверка должна передать ему точное значение и не присваивать себе его решение.
Граница готова к 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.
Проверяемый результат можно повторить на той же записи и получить тот же status. Если status меняется от текущих часов, сети, окружения или неявной нормализации, это уже не изолированная проверка. Если положительный status требует доверия к словам о запущенном сервисе, он выходит за границу примера. В обоих случаях работу нужно остановить и уточнить новый scope.
\nЧасть запроса проходит через PHP, затем попадает в JavaScript, а результат обрабатывает компонент на D. Пользователь получает ошибку без понятной причины, а оператор не может связать запись D с исходным запросом. На границе всё выглядит правдоподобно: поля называются одинаково, JSON похож, а число времени совпадает. Но один участник возвращает значение, другой выбрасывает исключение, третий записывает код отдельно. Это не совместимость, а потеря смысла, которую пока не видно.
\nЦена ошибки — повторная операция, пропущенный отказ или расследование без доказательства связи событий. Клиент может повторить уже принятую команду, worker — принять неполный ответ за успех, а команда — объявить проблему задержкой, хотя сравнивает разные шкалы времени. Статья показывает, как проверить одну узкую границу и получить воспроизводимый результат. Она не объявляет три языка совместимыми и не заменяет тест реального сервиса.
\nНужно ответить не на вопрос «могут ли три языка работать в одной системе», а на более точный: «сохраняет ли каждый участник заранее названный смысл конкретной записи». Для этого у записи должны быть версия схемы, операция, описанное значение, режим ошибки и основание времени. Участники сравниваются с этим контрактом по одинаковым правилам. Сравнение PHP с JavaScript напрямую не заменяет сравнение каждого из них с общей спецификацией.
\nТакой подход отделяет факт от предположения. Факт — в записи есть schemaVersion: 'boundary-1', значение помечено тегом order-ready, а интервал задан логическими шагами от 100 до 108. Предположение — что этот объект действительно прошёл через PHP, JavaScript и D. Поля model с названиями языков не превращаются в доказательство запуска. Для последнего нужны логи, транспорт, версии сборок и тест конкретного приложения.
JSON описывает синтаксическую форму, но не объясняет значение числа или строки. Число 4200 может быть суммой в минимальных единицах, лимитом, идентификатором или счётчиком. Строка ready может быть состоянием домена, текстом интерфейса или случайным результатом сравнения. Поэтому поле value.tag должно называться явно, а денежное значение — иметь единицу и валюту. Если потребитель угадывает смысл по имени поля, контракт уже неполон.
Операция и версия нужны для защиты от тихой подмены. Контракт для fixed-order-decision нельзя автоматически применять к другому действию только потому, что там тоже есть amountMinor. Версия сообщает, по какому набору правил читать запись. Это не версия PHP, Node.js или компилятора D. Версии runtime и библиотек остаются отдельными атрибутами интеграционного теста.
Ошибку тоже нужно описывать как данные о поведении. В примере error.semantics равен named-envelope, code может быть null, а retry имеет значение not-requested. Последняя строка не означает, что повтор безопасен: она лишь фиксирует, что данная запись не запрашивает повтор. Идемпотентность, эффект повторного вызова и политика клиента требуют отдельного контракта.
Время должно иметь basis. Фиксированные логические шаги подходят для проверки порядка в заранее созданном объекте. Они не являются миллисекундами, latency или SLA. Для наблюдаемой длительности нужны источник часов, единицы измерения, границы интервала и правило обработки рассинхронизации. Подстановка текущего времени в пропущенное поле делает пример менее воспроизводимым и скрывает ошибку.
Ниже — самостоятельный пример на JavaScript. Его можно сохранить в файл и запустить в среде с поддержкой современного синтаксиса JavaScript. Он проверяет только два заранее заданных объекта в памяти: полный набор участников и набор с изменённой семантикой ошибки. Пример не запускает PHP и D, не вызывает сеть, не читает системные часы, не измеряет скорость и не проверяет сериализацию.
\nconst 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. Такое преобразование допустимо только как явно реализованный адаптер с собственными тестами и правилами.
В коде намеренно проверяются наличие полей, целые границы времени и список допустимых участников. Проверка Object.hasOwn отличает явный null от отсутствующего поля. Это важно: отсутствие code может означать, что данные потеряны, тогда как code: null — осознанная часть конкретного формата. Проект может выбрать другую политику, но её нужно записать и одинаково применить к каждому участнику.
Отказ не доказывает, что реализация плохая. Он доказывает более узкое утверждение: текущая запись не позволяет честно объявить соответствие выбранному контракту. Причина должна вести к следующему действию. Для неполной версии нужно найти владельца схемы; для неразмеченного значения — определить единицу и тег; для смешанной ошибки — выбрать границу преобразования или сохранить разницу.
\nПроверка времени должна быть особенно строгой. Если closed отсутствует, нельзя вычислять его из текущих часов. Если basis одного участника — epoch seconds, а другого — логические шаги, совпадающие числа не создают общего интервала. Если нужны реальные миллисекунды, пример следует заменить измерением на конкретном пути и отдельно указать часы, нагрузку, версию сборки и способ повторения.
Нельзя исправлять отказ неявной coercion. Превращение строки в число, округление суммы или замена пустого значения default-ом может быть осмысленной операцией. Но она меняет границу данных. Её следует назвать, покрыть тестом на исходное и полученное значение и включить в версию адаптера. Пока правило не названо, статус exact будет ложным.
| Симптом | Вероятная причина | Минимальная проверка | Действие |
|---|---|---|---|
| Похожие JSON дают разные решения | Нет версии или value tag | Сверить схему, tag, единицу и валюту | Сделать поля обязательными; не выводить смысл из shape |
| Один участник бросает ошибку, другие возвращают объект | Смешаны режимы завершения | Сравнить semantics, code и retry | Ввести явный адаптер или остановить передачу |
| В отчёте появилась latency из двух чисел | Логические шаги приняты за часы | Проверить basis, источник и единицы | Убрать вывод о скорости или провести измерение |
| Повтор создаёт вторую операцию | Retry назван без идемпотентности | Проверить ключ операции и повторный эффект | Остановить retry до отдельного решения |
| Неполная запись считается успешной | Валидатор подставляет default | Удалить default и добавить missing-case | Вернуть именованную причину отказа |
| Лог D нельзя связать с запросом PHP | Нет общего correlation id | Проверить идентификатор в каждом событии | Добавить его в новый контракт; не связывать по времени |
Контракт отвечает за представление и правила передачи. Он не решает, разрешено ли выдавать заказ, имеет ли пользователь право на операцию или безопасно ли повторять вызов. Эти решения принадлежат доменному владельцу и должны иметь собственные условия и тесты. Метка order-ready описывает значение в технической записи, но не выдаёт бизнес-разрешение.
Адаптер отвечает за явное преобразование между своим представлением и контрактом. Он не должен скрывать потерю поля, менять валюту без правила или превращать исключение в успех. Если адаптер не может выразить состояние, правильный результат — отказ с причиной. Клиент отвечает за реакцию на эту причину, а не за угадывание пропущенного значения.
\nНаблюдаемость отвечает за доказательство реального пути. Для связи событий нужны correlation id, идентификатор операции, версия участника, время с известной шкалой и запись результата. Один фиксированный объект в памяти не содержит этих свидетельств. Поэтому его положительный статус нельзя переносить на production, нагрузочное сравнение или гарантию доставки.
\nadapter error не помогает исправлению.Модель не описывает ABI, правила приведения типов, сериализатор, сетевые таймауты, порядок доставки, транзакции, сборку мусора, планировщик и раскладку памяти. Эти свойства могут менять результат реальной интеграции. Их нельзя считать проверенными по совпавшему JSON. Для каждого свойства нужен отдельный источник, тест или наблюдение в заявленной среде.
\nВалидатор также не измеряет производительность. Числа 100 и 108 — проектные целые литералы, показывающие порядок и интервал внутри fixture. Они не означают 8 миллисекунд, 8 секунд или любую другую физическую величину. Нельзя сравнивать такой объект с production latency и делать вывод о быстродействии D, PHP или JavaScript.
\nОфициальная спецификация отдельного языка не является сертификатом межъязыковой совместимости. Документация PHP описывает его исключения, спецификация ECMAScript — семантику JavaScript, спецификация D — собственные исключения и безопасность их обработки. Общий API подтверждается только контрактом приложения и тестом всех границ. Если важное условие нельзя выразить в контракте, область вывода нужно сузить, а не заполнить догадкой.
\nГраница готова к интеграционному тесту, если другой инженер без устного объяснения может назвать операцию, версию, смысл каждого значения, режим ошибки, правило retry и шкалу времени. Для полного и неполного объектов есть разные ожидаемые статусы. Каждый участник имеет идентификатор, версию и exact mapping либо описанное преобразование. Отрицательные случаи не превращаются в успех за счёт default-ов.
\nИтоговая формулировка должна оставаться узкой: «этот набор полей соответствует контракту на уровне структуры и названных семантик». Формулировки «интеграция подтверждена», «латентность известна» и «три языка совместимы» требуют дополнительных доказательств. Если их нет, именованный отказ — полезный результат: он показывает, какое именно наблюдение нужно получить дальше.
\nthrow, catch, распространения исключения по стеку и требований к выбрасываемому объекту. Используется для ограничения утверждений о PHP, а не для доказательства общего API.Сервис принял заказ и вернул объект с полями amount, currency и status. PHP назвал статусом готовности строку ready. JavaScript обработал её как обычный результат. D получил тот же набор полей, но считает отсутствие кода ошибки отдельным состоянием. На границе всё выглядит одинаково. После сбоя команда видит три разных решения, а в логах остаётся один красивый JSON.
Цена такой ошибки — не только неверное сообщение. Клиент может повторить уже принятый заказ, worker может пропустить отказ, а расследование свяжет события по совпадающим полям, хотя они относятся к разным состояниям. Исправление обычно начинается с догадки: добавить retry, привести значение к строке или считать пустой код успехом. Каждая такая догадка расширяет зону риска.
\nТезис статьи простой: общий boundary contract должен называть не только поля, но и их смысл. Для минимальной проверки достаточно разделить три независимые оси: type отвечает за вид значения, error — за режим завершения, time — за определённую шкалу и порядок. Если одна ось не задана или подменена другой, проверка должна остановиться.
JSON переносит форму. Он не переносит договор о поведении. Число 4200 может означать сумму в копейках, лимит, внутренний идентификатор или случайный счётчик. Строка ready может быть именем состояния, текстом для интерфейса или результатом нестрогого сравнения. Без названного типа consumer вынужден угадывать.
Ошибки создают второй разрыв. Один runtime может вернуть объект результата с полем error. Другой может завершить операцию исключением. Третий может вернуть код и продолжить выполнение. Человек способен описать эти случаи одной фразой «обработка ошибки». Адаптеру такой фразы недостаточно: ему нужно знать, можно ли повторять операцию, сохранено ли значение и кто владеет решением.
Время создаёт третий разрыв. Длительность из monotonic clock нельзя без оговорки сравнивать с календарным timestamp. Два числа без шкалы не доказывают latency. Даже одинаковые начало и конец могут быть только порядковыми метками внутри тестового объекта, а не наблюдением работающей системы.
\ntype должен содержать named tag. Не выводите его из соседних полей. В примере order-ready — это фиксированная метка значения, а amountMinor и currency — дополнительные поля с собственной единицей и форматом. Если адаптер заменяет tag числом или оставляет его пустым, shape больше нельзя считать сохранённым.
error должен описывать режим завершения. Удобная минимальная форма — semantics, code и retry. Значение code: null означает отсутствие кода в данном envelope. Оно не означает «в системе ошибок нет». Значение retry: not-requested не запускает повтор и не обещает, что повтор безопасен. Для этого нужны отдельные правила идемпотентности.
time должен называть basis и обе границы интервала. Fixed logical ticks подходят для проверки порядка внутри заранее заданной записи. Они не являются миллисекундами. Если контракт требует наблюдаемую длительность, ему нужны источник измерения, единицы, точка начала и точка окончания. Нельзя подставить текущие часы, чтобы получить зелёный результат.
Три оси проверяются отдельно. Ошибка не сообщает тип значения. Числовое поле не задаёт единицу времени. Наличие timestamp не подтверждает, что операция завершилась успешно. Такое разделение кажется избыточным только до первого неоднозначного отказа.
\nНиже приведён ограниченный JavaScript-пример. Он проверяет только объект в памяти и возвращает причину остановки. Он не запускает PHP или D, не вызывает сеть, не читает часы, не измеряет производительность и не подтверждает поведение сервиса. Его задача — показать форму fail-closed проверки.
\nconst 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 и формат сериализации в этот объект не входят.
В реальном коде такой boundary нужно реализовать в согласованном контракте и покрыть тестами конкретного продукта. Нельзя скопировать функцию и объявить интеграцию проверенной. Учебный объект специально маленький: он помогает увидеть missing field и смешанную семантику, но не заменяет контракт API.
\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-причину владельцу контракта |
Положительный объект удобен, но он почти ничего не говорит о дисциплине контракта. Настоящая проверка начинается с испорченной записи. Удалите schemaVersion. Ожидаемый результат — stop-incomplete-contract. Не подставляйте текущую версию автоматически: иначе тест перестанет замечать несовместимый участник.
Замените у JavaScript-участника errorSemantics на thrown-value. Ожидаемый результат — stop-incomparable-adapter. Проверка не должна оборачивать исключение в envelope задним числом. Такое преобразование может быть правильным решением продукта, но тогда оно должно быть отдельным адаптером с названными правилами, а не скрытой операцией review.
Измените time.basis на wall-clock и оставьте closed: null. Ожидаемый результат — stop-undetermined-time-boundary. Нельзя сказать «интервал неизвестен, но примерно короткий». В этом объекте нет факта, который поддерживает такую оценку.
Такой путь защищает и от незаметной нормализации. Coercion может сделать данные удобнее для одного consumer, но скрыть различие между целым числом и tagged value. Если преобразование нужно, его надо назвать, версионировать и проверить как новую границу. Молчаливое приведение не является доказательством совместимости.
\nКонтрактная матрица не делает разные языки одинаковыми. Она не описывает сборщик мусора, правила приведения типов, исключения, ABI, сериализатор или планировщик. Эти свойства могут влиять на интеграцию и требуют отдельных источников и тестов. Матрица только не даёт спрятать их за одинаковым полем.
\nДаже официальный документ языка отвечает на вопрос о данном языке, а не о совместимости трёх систем. Например, строгая типизация PHP может изменить момент отказа, completion record ECMAScript описывает семантику спецификации JavaScript, а D документирует собственную обработку ошибок. Ни один из этих фактов сам по себе не доказывает общий API.
\nМатрица также не проверяет бизнес-смысл суммы, права пользователя, повторную доставку сообщения или транзакцию. Для них нужны свои поля, владельцы и отрицательные сценарии. Если boundary не может выразить важное условие, нельзя считать его достаточным только потому, что все участники прошли текущую проверку.
\nГраница готова к следующему техническому тесту, если другой инженер без устного пояснения может восстановить: какую операцию описывает запись, какой tag означает допустимое значение, как кодируется ошибка, что означает retry, какая шкала времени используется и какой результат даёт каждый отрицательный случай.
\nДополнительное условие — все три участника сохраняют contract shape без неявного приведения, а положительный результат не содержит утверждения о latency, deployment или реальном поведении среды. Если хотя бы одно поле приходится угадывать, проверка должна закончиться именованной stop-причиной. Это и есть полезный результат: команда видит границу знания до того, как похожий payload станет ошибочным действием.
\nTypeError в PHP. Поведение конкретного boundary зависит от его кода и версии.Наблюдаемый симптом — один и тот же заказ получает разные решения. В одном заказе три участника видят почти одинаковый JSON: amount, currency и status. PHP считает status=ready успешным завершением. JavaScript проверяет только наличие строки и продолжает обработку. D ждёт отдельный код результата и оставляет операцию незавершённой. На экране это один payload, но решения уже расходятся.
Цена расхождения появляется после сбоя. Клиент может повторить уже принятый заказ, worker — пропустить отказ, а оператор — связать события по совпавшим полям, хотя они относятся к разным стадиям. Попытка быстро исправить ситуацию обычно выглядит невинно: привести значение к строке, подставить код по умолчанию или включить retry. Но каждое молчаливое преобразование переносит неопределённость дальше по цепочке.
\nЗдесь полезно проверять не сходство объектов, а сохранение смысла. Минимальная граница состоит из версии схемы, именованного типа значения, режима ошибки и шкалы времени. Если хотя бы одна ось не описана, адаптер должен остановиться с конкретной причиной. Такой подход не делает языки одинаковыми; он показывает, в каком месте они перестают быть сопоставимыми.
\nJSON описывает синтаксическую форму обмена, но не решает, что означает поле amount или можно ли повторить операцию. Число 4200 может быть суммой в копейках, лимитом или идентификатором. Строка ready может быть именем состояния, текстом интерфейса или значением, которое случайно прошло нестрогое сравнение. Одинаковый shape не является доказательством одинакового поведения.
Поэтому полезная запись на границе называет смысл явно. В учебном примере value.tag фиксирует тип полезной нагрузки, amountMinor — целое число в минимальных денежных единицах, а currency — код валюты. Это проектные решения конкретного примера, а не свойства PHP, JavaScript или D. Их нужно закрепить в API-схеме и тестах своего продукта.
Версия схемы отвечает на вопрос «какую форму мы сейчас проверяем». Она не заменяет версию runtime и библиотеки. Если PHP меняет правила приведения скаляров, а JavaScript или D иначе представляют ошибку, одна версия JSON не устраняет различие. Версия нужна, чтобы не подменять новый договор старым объектом; остальные зависимости проверяются отдельно.
\ntype должен быть именованным тегом, а не выводом из соседних полей. Без него адаптер не знает, является ли 4200 суммой или лимитом. Тег также не заменяет проверку содержимого: после order-ready всё равно нужно проверить диапазон суммы, код валюты и обязательные поля.
error описывает не текст сообщения, а режим завершения. В примере есть semantics, code и retry. Пара code: null и semantics: named-envelope означает только отсутствие кода в этой записи. Она не доказывает, что в системе не было ошибки. Поле retry сообщает намерение или результат политики, но не делает повтор безопасным без ключа идемпотентности и правила побочных эффектов.
time должен назвать шкалу и обе границы интервала. В локальном примере используются фиксированные логические такты: по ним можно проверить порядок opened <= closed. Это не миллисекунды и не измерение производительности. Для latency понадобятся источник часов, единицы измерения, точки старта и окончания, а также правило, где именно начинается операция.
adapter фиксирует перевод между внутренним представлением и этим envelope. Участник не может объявить себя совместимым по одному имени: проверяются версия, tag, режим ошибки, шкала времени и способ mapping. Если в одном месте происходит coercion, это отдельное правило преобразования с тестами, а не «точное» сопоставление.
Официальная документация PHP прямо описывает важную ловушку: по умолчанию скалярные значения могут быть приведены к объявленному типу, а declare(strict_types=1) меняет проверку для вызовов из конкретного файла. Несовпадение может закончиться TypeError. Поэтому PHP-тип параметра нельзя автоматически считать описанием внешнего JSON-контракта: поведение зависит от места вызова и от того, где стоит граница сериализации.
Спецификация ECMAScript описывает language types и completion records JavaScript. Это модель выполнения программы, а не готовый HTTP-envelope с полями code и retry. Преобразование исключения или результата в такой envelope — решение адаптера. Его нельзя приписать самому JSON или назвать общим свойством всех трёх runtime.
Спецификация D описывает собственную модель ошибок и раскрутки стека. Она помогает понять поведение D-кода внутри его среды, но не задаёт внешний договор с PHP и JavaScript. На транспортной границе нужно отдельно решить, какие ошибки становятся error.code, что происходит с незавершённой операцией и кто может инициировать повтор.
Это различие важно для расследования. Фраза «язык вернул ошибку» слишком широка. Нужно записать наблюдаемый слой: тип значения до сериализации, байты или JSON на транспорте, результат десериализации, решение адаптера и побочный эффект операции. Только так можно понять, где исчезло поле или возникло неявное приведение.
\nНиже — самостоятельный пример для Node.js 18+. Он проверяет объект в памяти и не вызывает сеть, часы, PHP или D. Все значения внутри него учебные. Смысл функции в другом: неполная запись получает именованную причину остановки, а положительный результат появляется только при полном наборе независимых признаков.
\nconst 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. На последнем шаге адаптеры сравниваются с записью, а не друг с другом: взаимное совпадение трёх одинаково ошибочных переводов не считается доказательством.
Пример можно запустить, сохранив код в чистый файл и выполнив node checker.mjs. Для настоящего API следует заменить учебную запись схемой продукта, добавить проверку неизвестных полей и зафиксировать правила сериализации. Результат функции не является сертификатом совместимости: он показывает, что именно проверено и где проверка отказалась делать вывод.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Одинаковый shape даёт разные решения | Не названы tag, версия или единицы полей | Сверить обязательные поля до бизнес-ветки | Расширить envelope; не выводить смысл из shape |
| Один участник бросает ошибку, другие возвращают объект | Смешаны режимы завершения | Сопоставить semantics, code и момент завершения | Задокументировать адаптер или остановить mapping |
| После таймаута создаётся второй заказ | retry есть, а идемпотентность не определена | Повторить вызов с тем же ключом на тестовой записи | Запретить автоматический повтор до правила эффекта |
| Отчёт говорит «быстрее», но метрики нет | Логические такты приняты за latency | Проверить basis, часы, единицы и границы | Удалить вывод о скорости или завести отдельное измерение |
| Неполный объект считается успешным | Валидатор подставляет default | Удалить поле и проверить точную stop-причину | Сделать обязательность явной и покрыть missing-case |
Положительная запись показывает только счастливую ветку. Дисциплину проверяют изменения, которые инженер обязан отвергнуть. Удалите schemaVersion или замените operation: функция возвращает stop-incomplete-contract. Она не угадывает версию по текущей сборке и не пытается «помочь» вызывающему коду.
Замените value.tag на неизвестное значение. Ожидаемая причина — stop-incomplete-value: запись больше не соответствует выбранному типу. Чтобы проверить несовместимый адаптер, измените у JavaScript-участника valueTag. Тогда функция вернёт stop-incomparable-adapter. То же происходит, если JavaScript сообщает thrown-value вместо named-envelope. Нельзя объявить эти варианты равными только потому, что оба заканчиваются словом «ошибка».
Поставьте time.closed: null или поменяйте basis на wall-clock. Результат — stop-undetermined-time-boundary. Если нужна настоящая длительность, добавьте отдельный контракт с единицами и источником измерения. Не переводите условный такт в миллисекунды задним числом.
Проверьте также дубликат модели: замените D-участника вторым PHP-участником. Функция вернёт stop-incomplete-adapter-set. Это маленькая деталь, но без неё матрица доказывает лишь три строки данных, а не участие трёх заявленных runtime. Такой отрицательный тест должен жить рядом с положительным и запускаться на каждое изменение схемы.
null и поведение неизвестных полей.Матрица проверяет согласованность выбранного envelope, а не эквивалентность языков. Она не описывает сборщик мусора, правила ABI, сериализатор, планировщик, права, транзакции, порядок доставки сообщений или лимиты сети. Каждое из этих свойств может изменить результат и требует собственной проверки.
\nДаже официальный документ языка отвечает на вопрос о языке, а не о вашем API. PHP может привести скаляр до вызова функции; JavaScript может завершить функцию значением или исключением; D использует свою модель ошибок. Между этим поведением и внешним JSON находится ваш код. Именно его контракт и тесты должны объяснить, что увидит соседний участник.
\nУчебный пример допускает только три фиксированные модели и одну шкалу времени. В рабочем проекте может быть больше адаптеров, несколько версий схемы или асинхронная доставка. Тогда нужно версионировать набор правил и явно описать совместимость между версиями. Нельзя расширить список участников молча и сохранить старый критерий готовности.
\nНаконец, наличие error.code не доказывает, что операция безопасна для повтора. Для этого нужны идемпотентный ключ, граница фиксации эффекта и тест повторной доставки. Если такие условия не помещаются в текущую запись, вывод ограничивается проверкой формы и не распространяется на бизнес-результат.
Граница готова к интеграционному тесту, если инженер без устного пояснения может назвать операцию, версию схемы, допустимый tag, единицы каждого поля, режим ошибки, смысл retry и шкалу времени. Для каждого отрицательного случая заранее известна точная причина остановки.
\nДополнительно должны выполняться три условия: каждый runtime проходит через явный адаптер; преобразования записаны и покрыты тестом; положительный результат не утверждает latency, deployment или успешную транзакцию, если эти свойства отдельно не наблюдались. Если одно поле приходится угадывать, правильный результат проверки — отказ с именем причины, а не зелёная строка для удобства отчёта.
\nTypeError. Конкретный boundary зависит от места вызова и версии PHP.Симптом обычно выглядит безобидно: PHP возвращает объект заказа, JavaScript показывает его как готовый, а D-обработчик принимает тот же пакет после адаптации. В логах остаются одинаковые поля, но в редком случае одно отсутствие превращается в 0, другая ветка сохраняет исключение, а третья считает время по другой шкале. Ошибка обнаруживается уже после передачи данных. Цена — неверное решение, повторная обработка или часы разбора, потому что команда спорит о runtime вместо формы сообщения.
Тезис простой: общий контракт нужно проектировать на границе задачи, а не выводить из сходства языков. Для учебной проверки достаточно одного объекта в памяти. В нём надо явно назвать операцию, вид значения, семантику ошибки, шкалу времени и правила адаптеров. Если хотя бы одно поле нельзя сравнить, проверка должна остановиться. Такой результат подтверждает только внутреннюю согласованность модели. Он не подтверждает работу PHP, JavaScript, D или production-сервиса.
\nJSON-подобная форма скрывает решения. Число может означать деньги в минимальных единицах, счётчик или результат преобразования. Пустое поле может означать отсутствие значения, ошибку или значение по умолчанию. Время может быть timestamp, длительностью или логическим порядком событий. Если контракт не называет эти свойства, каждый адаптер заполняет пробел своим правилом.
\nНужен узкий boundary contract. Он не пытается описать всю систему и не переносит внутренние классы, stack trace, сборщик мусора или планировщик. Он отвечает на один вопрос: сохраняют ли три представления одну заранее названную форму. Поэтому в нём нет неявного default. Отсутствующее поле ведёт к отказу, а не к удобной подстановке.
\nВ примере операция называется fixed-order-decision. Поле value.tag отделяет вид значения от его представления. amountMinor: 4200 — учебное целое число; оно не объявляет денежный протокол и не должно автоматически превращаться во float. Поле error использует именованный конверт: в нём есть семантика, код и правило повтора. Это не объект исключения и не текст сообщения.
Время задаётся двумя упорядоченными логическими отметками. Числа 100 и 108 дают разность восемь внутри учебной шкалы. Они не являются timestamp и не показывают latency. Каждый адаптер получает ту же версию схемы, тот же tag, ту же семантику ошибки, ту же шкалу времени и результат exact. Приведение типа скрывает потерю смысла, поэтому его надо отклонять.
| Поле | Зачем оно нужно | Когда остановиться |
|---|---|---|
schemaVersion | Связывает верхний объект и адаптеры. | Версия пустая или различается. |
value.tag | Называет вид значения до преобразования. | Tag отсутствует или подменён. |
error | Фиксирует code и retry без object identity. | Нет именованного конверта. |
time | Задаёт одну сравнимую шкалу. | Нет двух упорядоченных отметок. |
mapping | Показывает сохранение формы. | Используется coercion вместо exact. |
Ниже выполняется только JavaScript-код, который читает заранее заданный объект. Строки php, javascript и d — метки взглядов на форму, а не запущенные процессы. Пример полезен для проверки правил и отрицательных веток. Он не доказывает совместимость библиотек, транспортов или окружений.
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 за доказательство, что три системы связаны.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Пропущенный amount читается как ноль. | Нет отдельного tag для отсутствия. | Сверить value.tag и наличие поля. | Добавить именованный вариант или остановить проверку. |
| Один адаптер хранит thrown value. | Смешаны semantics ошибки. | Сравнить errorSemantics буквально. | Выровнять конверт или вернуть stop-incomparable-adapter. |
| Для одного ответа считают duration. | Нет общей шкалы и закрывающей отметки. | Проверить basis, opened и closed. | Задать ordered fixed ticks; не подставлять часы. |
| Адаптер возвращает похожее число. | Форма прошла coercion. | Проверить mapping на exact. | Убрать приведение или описать новое поле и версию. |
| Пример называют интеграционным тестом. | Метки моделей приняли за процессы. | Перечислить реально запущенные компоненты. | Сузить вывод до проверки объекта в памяти. |
Пустая версия или отсутствующий адаптер должны вернуть stop-incomplete-contract. Не надо принимать частичный объект ради продолжения разбора. Если JavaScript записывает thrown-value, а два других адаптера используют named-envelope, результат — stop-incomparable-adapter. Это не утверждение о поведении языков. Это точное описание несовпадения полей.
Если closed отсутствует или basis равен wall-clock, верните stop-undetermined-time-boundary. Нельзя восстановить длительность из незаписанного события и нельзя превратить учебные ticks в метрику. Если новые требования делают эти поля недостаточными, создайте новую версию контракта. Не прячьте смысл в поле metadata.
Модель не описывает nullable semantics, ABI, сериализацию, transport, версии пакетов, доступы, retry конкретного клиента или бизнес-значение заказа. Она не запускает PHP, D или отдельный JavaScript runtime, не читает сеть и диск, не использует часы, не собирает telemetry, trace или profile и не содержит пользовательских данных. Поэтому из неё нельзя вывести latency, SLA, безопасность, совместимость релиза или готовность deploy.
\nВ production такой контракт может стать частью отдельного теста совместимости, но это потребует реальных входов, владельцев, версий и наблюдаемого результата. Учебный объект не заменяет этот тест. Он только не даёт начать разговор с ложного утверждения.
\nМатериал и его пример готовы, если независимый читатель может повторить проверку по одному объекту и получить одно из двух: accepted с перечисленными полями или точный stop reason. При accepted все три model label присутствуют, версия совпадает, tag и error semantics совпадают, время упорядочено, mapping равен exact, а итог прямо говорит externalEffect: not-checked. При отказе причина указывает на конкретное недостающее поле. Ни один результат не использует слова «интеграция подтверждена» без отдельного runtime-доказательства.
На границе одного сервиса с другим ответ выглядит одинаково: PHP сформировал заказ, JavaScript показал его в интерфейсе, а обработчик на D получил те же поля для следующего шага. Проблема появляется не в формате, а в решении. Один потребитель считает отсутствие error успехом, второй ждёт исключение, третий подставляет ноль вместо пропущенной суммы.
Цена расхождения быстро становится практической. Интерфейс может показать готовый заказ, worker — повторить уже выполненную операцию, а расследование не свяжет запись D с исходным запросом PHP. Команда видит один JSON и спорит о языках, хотя сначала надо ответить на более узкий вопрос: какие значения, состояния и правила этот JSON обязан сохранять?
\nВ этой заметке я использую маленький boundary contract — договор на стыке компонентов. Он не описывает всю систему. Он фиксирует одну операцию, версию схемы, тип результата, режим завершения, шкалу времени, идентификатор связи и правило преобразования. Если поле нельзя сравнить буквально, проверка останавливается и возвращает причину.
\nJSON удобен именно своей малой грамматикой: объект, массив, строка, число, логическое значение и null. RFC 8259 описывает его как текстовый формат обмена структурированными данными. Но в этих типах нет ответа на вопросы «можно ли повторить операцию», «что означает пустое поле» и «кто владеет отказом».
Даже поле amount: 4200 не сообщает единицу. Это могут быть копейки, рубли, лимит или внутренний счётчик. Число также имеет границу переносимости: RFC 8259 отдельно отмечает точное согласование целых чисел в диапазоне от -(2**53)+1 до (2**53)-1 для реализаций с IEEE 754 binary64. Значит, «число в JSON» — ещё не денежный тип. Единицу и допустимый диапазон задаёт контракт приложения.
Имена объекта должны быть уникальными: при дубликатах разные реализации могут оставить последнее значение, вернуть ошибку или сохранить несколько пар. Это ещё одна причина не строить протокол на случайном поведении парсера. На границе нужны уникальные поля и проверяемые правила, а не надежда на одинаковую реакцию библиотек.
\n| Слой | Пример | Вопрос для проверки |
|---|---|---|
| Форма | result.tag и набор полей | Ответ можно разобрать без догадок? |
| Единица | amountMinor в RUB | Одинакова ли шкала значения? |
| Состояние | outcome.kind: accepted | Это успех, отказ или незавершённая операция? |
| Время | basis: logical-ticks | Можно ли сравнить начало и конец? |
| Связь | correlationId | По какому ключу искать одну операцию? |
| Преобразование | mapping: exact | Значение сохранено или незаметно изменено? |
Широкий объект «данные заказа» плохо проверяется. В нём смешиваются бизнес-решение, транспортные детали, диагностические поля и состояние побочного эффекта. Для первой версии лучше выбрать одну операцию, например fixed-order-decision, и описать только результат этой операции.
У каждого поля должен быть владелец смысла. Producer отвечает за то, что order-ready означает именно готовый результат, а не текст для интерфейса. Consumer не должен угадывать смысл по имени поля или по тому, что значение похоже на знакомый тип. Он принимает только известную версию и явно отказывается от незнакомой.
Для демонстрации подойдёт фиксированная запись: сумма хранится в минимальных единицах, валюта названа отдельно, ошибка представлена envelope, а время задано логическими отметками. Такая запись воспроизводима: её результат не зависит от часов, сети, файлов и случайного порядка выполнения.
\nЗначение. Поле result.tag отделяет вид результата от его хранения. Для суммы нужны amountMinor, currency и правило диапазона. Не следует превращать пропущенную сумму в ноль: это два разных состояния, и у них должны быть разные tag или явное состояние отсутствия.
Завершение. Поле outcome.kind отвечает на вопрос, чем закончилась операция. В примере допустимы accepted и rejected, а error содержит стабильный code и правило retry. Текст сообщения можно показывать человеку, но нельзя делать его единственным ключом для автоматики.
Механизмы языков здесь различаются. PHP Manual описывает throw, catch и подъём исключения по стеку до обработчика. Спецификация ECMAScript использует Completion Record с типами normal и throw для описания значения и передачи управления. Документация D также строит обработку вокруг исключений и размотки стека. Эти источники объясняют механизмы внутри языков, но не создают общего протокола. На границе исключение надо явно сопоставить с полями envelope либо вернуть отказ.
Время. logical-ticks в примере нужны только для проверки порядка: closed: 108 больше opened: 100. Это не миллисекунды и не измерение задержки. Если продукту нужна длительность, контракт должен назвать источник часов, единицы, точку старта и точку окончания. Календарная дата, monotonic clock и порядковая отметка решают разные задачи.
Связь. correlationId связывает записи одной операции, но не доказывает, что запрос дошёл до следующего компонента. Уникальный идентификатор помогает искать события; доказательство доставки требует отдельного наблюдаемого результата. Не надо восстанавливать связь по совпавшему времени или одинаковой сумме.
| Ось | Явное поле | Неверная подмена | Остановка |
|---|---|---|---|
| Результат | tag: order-ready | Выводить тип по наличию amountMinor | stop-unknown-result-tag |
| Ошибка | kind, code, retry | Считать отсутствие исключения успехом | stop-mixed-outcome |
| Время | basis, opened, closed | Называть ticks миллисекундами | stop-unknown-time-basis |
| Связь | correlationId | Искать событие по timestamp | stop-missing-correlation |
Ниже обычный JavaScript без внешних зависимостей. Он проверяет заранее заданные literals, поэтому его можно сохранить в boundary-check.mjs и выполнить командой node boundary-check.mjs. Запись с меткой php не запускает PHP, а запись d не запускает D: это участники проверочной матрицы.
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, получится проверяемый результат, но не тест трёх языков и не тест сетевого обмена.
Контракт начинает жить дольше одного примера, когда у него появляется владелец и политика изменений. Producer публикует версию и fixture. Каждый consumer проверяет известные версии на своих входах. Тест должен отличать добавление необязательного поля от изменения смысла существующего.
\nДобавление нового поля обычно безопаснее, если старый consumer обязан его игнорировать и это правило записано. Переименование amountMinor, изменение единицы или превращение null в допустимый ноль — уже изменение смысла. Для такого шага нужна новая версия либо явная миграция. Поле metadata не должно становиться складом неоговорённых исключений.
Полезно держать отдельные fixtures для нормального результата, отказа, неполного объекта и неизвестной версии. В каждом fixture фиксируются вход, ожидаемый status и причина. Тогда изменение адаптера вызывает понятный diff теста, а не спор по логам после выката.
\n| Симптом | Проверка | Действие |
|---|---|---|
| Пропущенная сумма стала нулём | Сверить tag, наличие поля и единицу | Вернуть отказ или ввести отдельный вариант отсутствия |
| Интерфейс видит успех, worker повторяет операцию | Сравнить outcome.kind, error.code и retry | Сделать решение явным и проверить идемпотентность отдельно |
| Лог D не находится по запросу PHP | Проверить общий correlationId на каждом переходе | Добавить идентификатор в новую версию и прокинуть его без замены |
| Одинаковое время даёт разные выводы | Сверить time.basis и обе точки интервала | Не вычислять latency из логических ticks |
| Один адаптер «почти» совпал | Проверить mapping на exact | Описать преобразование отдельным правилом либо остановить обмен |
Пример намеренно мал. Он не проверяет ABI, сериализацию конкретной библиотеки, кодировку транспорта, версии PHP/Node/D, схему базы, права, таймауты, повтор после частичного побочного эффекта, безопасность, нагрузку или SLA. Он также не показывает, что три компонента действительно обменялись данными. Для этого нужны реальные точки входа, тестовый стенд, логи или трассировка и известный владелец результата.
\nФиксированные logical ticks нельзя превращать в latency, а correlationId — в доказательство доставки. amountMinor с валютой не заменяет правила округления, возврата и финансового учёта. Envelope с retry: never не доказывает идемпотентность операции. Эти свойства должны пройти свои проверки на уровне продукта.
Если контракт должен поддержать иной результат, другую шкалу времени или новый способ обработки ошибки, это не повод молча ослабить validator. Добавьте поле, версию и fixture, затем повторите проверку. Fail-closed путь сохраняет неизвестное состояние видимым для владельца и не выдаёт удобное значение за подтверждённый смысл.
\nГраница подготовлена к следующему инженерному тесту, если независимый разработчик может взять fixture и получить тот же status без доступа к истории переписки. У записи есть одна операция и версия; результат имеет tag и единицы; outcome отделён от result; time имеет basis и две упорядоченные точки; correlationId сохраняется; каждый адаптер проходит exact mapping; отрицательные варианты возвращают конкретные stop reasons.
\nПосле этого ещё нельзя писать, что PHP, JavaScript и D совместимы вообще. Можно сказать только: фиксированная запись соответствует выбранным правилам и готова перейти к отдельной проверке транспорта и среды. Такое утверждение уже достаточно полезно: оно показывает, что проверено, где заканчивается доказательство и какой следующий эксперимент нужен.
\nПользователь ждёт страницу десять секунд, а график CPU держится на двадцати процентах. Разработчик видит свободный процессор и меняет запрос, добавляет поток или увеличивает таймаут. Иногда это случайно скрывает симптом. Часто задержка остаётся: запрос ждал допуска в очередь, соединение с базой или ответ внешней системы. Цена ошибки — лишний релиз, рост нагрузки и потеря исходного сигнала. После изменения уже трудно восстановить исходные условия сравнения.
\nТезис простой: низкая загрузка CPU не опровергает медленный запрос. Сначала разложите end-to-end интервал на наблюдаемые части и назовите границу сравнения. Только после этого выбирайте действие. Один trace показывает структуру пути. Он не доказывает, что изменение ускорит систему.
\nОбщее время запроса включает ожидание и работу. Запрос может стоять в очереди, пока CPU свободен. Он может ждать соединение, блокировку строки, диск, DNS, TLS или ответ удалённого сервиса. В эти моменты процессор не обязан быть занят. Метрика CPU отвечает на вопрос о занятости вычислительного ресурса, но не о времени ответа конкретного запроса.
\nTrace отделяет участки пути, если дерево полно и интервалы используют одну временную основу. Root span задаёт end-to-end границу. Дочерний span показывает названную операцию внутри неё. Если дочерний интервал не покрывает разницу, остаток остаётся неизвестным. Его нельзя без отдельного сигнала назвать очередью, сетью или базой.
\nСравнение требует второй границы. Записи до и после изменения должны иметь один класс входа, одинаковое число запросов, одинаковую конкурентность и одинаковую форму данных. Если один прогон обрабатывает 12 запросов при concurrency 3, а второй — 24 при concurrency 6, разница времени ничего не говорит об изменении кода. Более короткий интервал может означать другую нагрузку.
\nНиже — учебный пример с заранее заданными значениями. Он не обращается к сети, базе, часам или профайлеру. Единицы условны. Код показывает проверку структуры, а не результат работы сервиса.
\nconst 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. Это повод проверить очередь отдельным сигналом. Код не доказывает, что очередь является корнем задержки, что база виновата или что удаление очереди ускорит пользователя.
Отрицательный путь важнее короткого положительного. Если у span parent равен missing-01, функция возвращает stop-incomplete-trace. Если записи до и после изменения используют разные поля load, их нельзя сравнивать. Если в записи стоит effect: 'faster-after-change', это не измерение. Такое утверждение нельзя принимать без наблюдаемых данных.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| CPU низкий, запрос медленный | Очередь, блокировка или внешний ответ входит в end-to-end время | Открыть trace и разделить ожидание, локальную работу и дочерние вызовы | Назвать только покрытый span; неизвестный остаток оставить unknown |
| Самый длинный span совпал с пиком latency | Span включает ожидание upstream или retry | Проверить parent/child, status, retry count и дочерние интервалы | Не объявлять span причиной; добавить недостающую границу |
| Вторая запись короче первой | Изменилась нагрузка, cohort или форма входа | Сверить requests, concurrency, cohort и input shape | Снять сравнение и повторить с одной control boundary |
| Дочерний span без parent | Потеря записи, ошибка экспорта или неверный ID | Проверить полный экспорт, уникальность ID и формат связи | Вернуть stop; не дорисовывать дерево по времени |
| После изменения есть одна короткая запись | Нет сопоставимой пары и распределения наблюдений | Сравнить тот же сценарий до и после на заданном окне | Назвать observation, а не improvement |
Сначала найдите root span и его границы. Затем проверьте, что каждый дочерний span имеет существующего родителя, начало не позже конца, а единицы времени совпадают. Интервалы могут перекрываться. Нельзя складывать все длительности и получать время ответа: параллельные операции будут посчитаны дважды.
\nЕсли root длится 1000 условных единиц, очередь — 520, база — 150, а каталог — 200, сумма дочерних интервалов равна 870. Она не означает, что оставшиеся 130 — сеть. Часть времени могла пересекаться, а часть могла прийтись на неразмеченную работу. Корректная формулировка: «в записи есть 130 единиц, которые не покрыты названными span-ами». Их нельзя приписывать компоненту без отдельной границы.
\nВремя ожидания и время исполнения также нельзя смешивать. База могла выполнить запрос быстро после освобождения соединения. Внешний вызов мог вернуть ответ быстро, но запрос долго ждал его начала. Название span должно отражать проверенное содержание. db-call не равно «всё время до базы», если выдача соединения записывается отдельно.
Узкий результат может быть полезным. Например: «В trace-01 при load-a root равен 1000 условных единиц. Названный queue span занимает 520. Дерево связано. Сравнение до и после не выполнялось». Это указывает на очередь как на место для отдельной проверки. Формулировка не содержит обещания исправления.
\nСильнее звучит, но не следует из записи: «очередь стала bottleneck», «изменение БД ускорит путь» и «latency снизилась». Для каждого утверждения нужна отдельная граница доказательства. Нельзя получить контрфактический эффект из одного trace: он не показывает, что произошло бы без выбранного вызова или при другой конкуренции.
\nSampling может убрать нужный span. Collector может потерять запись или доставить события не по порядку. Асинхронный worker может продолжить работу после root span. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезным, но снижают силу вывода. Их нужно записать рядом с наблюдением.
\nКарта интервалов не заменяет нагрузочный тест. Она не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Она также не задаёт SLA. HTTP-стандарт описывает семантику запроса и ответа, а не конкретный бюджет latency. Для этих вопросов нужны собственные измерения и критерии.
\nУчебный код нельзя считать проверкой реальной телеметрии. В нём заранее заданные числа, одна запись и известные поля. Он не проверяет экспорт, трассировку через прокси, поведение клиента и права доступа. Результат для работающего сервиса появляется только после измерения в описанной среде.
\nПроверка достаточна для технического вывода, если другой инженер получает тот же вход и без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу нагрузки, отличить названную задержку от unknown и воспроизвести stop на неполном trace или несопоставимой нагрузке. Сравнение до и после допустимо, если записи сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а новый результат не маскирует ошибку ростом таймаутов или потерей сигнала.
\nЕсли критерий не выполнен, вывод ограничивается нехваткой данных. Нельзя выбирать знакомый компонент только потому, что он виден на графике.
\nСимптом выглядит так: пользователь ждёт ответ десять секунд, а график CPU держится на двадцати процентах. В такой ситуации легко объявить виновным запрос к базе, добавить поток или увеличить timeout. Но низкий CPU не противоречит длинному ответу: запрос мог ждать очередь, соединение из пула, блокировку, диск или внешний сервис. Неизвестное ожидание не превращается в причину оттого, что оно попало в один end-to-end интервал.
\nРазберём учебный полевой сценарий: один HTTP-запрос, его trace и фиксированная контрольная нагрузка. Цель не в том, чтобы угадать узкое место по самому длинному отрезку. Нужно отделить наблюдение от гипотезы, назвать недостающий сигнал и только потом выбрать небольшое изменение. Такой порядок оставляет после расследования не впечатление, а запись, которую другой инженер сможет проверить.
\nДо изменения кода сохраните маршрут, метод, статус, длительность, размер ответа, timestamp и идентификатор запроса. Добавьте число логических запросов, concurrency, форму входных данных и версию конфигурации. Эти поля задают контрольную границу: без неё сравнение «до» и «после» может измерять разные условия.
\nCPU — метрика ресурса, а не таймер конкретного запроса. Процессор может простаивать, пока поток ждёт свободное соединение или ответ удалённого сервиса. Даже высокий CPU не доказывает причину: горячий участок мог работать параллельно с ожиданием, а агрегированная метрика могла скрыть один перегруженный worker. В начале записи отделите факт от предположения: «root длился 10 000 мс, CPU процесса был около 20%» — факт; «запрос тормозит из-за базы» — пока гипотеза.
\nЕсли trace отсутствует или связывает только часть пути, сила вывода ограничена. Запишите это сразу. Попытка восстановить дерево по времени логов полезна как поиск следующего сигнала, но не заменяет корректную parent/child-связь.
\nTrace — это путь одной операции через систему. Span — отдельная единица работы внутри этого пути. Корневой span обычно описывает всю операцию, а дочерние span-ы — её подоперации. У каждого интервала должны быть начало, конец, идентификатор и связь с родителем. Эта модель помогает спросить: «какой участок наблюдается?» Она не отвечает автоматически на вопрос: «что произойдёт после изменения?»
\nEnd-to-end время включает и работу, и ожидание. Очередь допуска, выдача соединения, блокировка строки, DNS, TLS, чтение диска, retry и ожидание ответа партнёра могут занимать время при низком CPU. Название span должно соответствовать записанной операции. db-call не означает всё время до базы, если ожидание соединения записано за его пределами или не записано вовсе.
Следите за двумя границами. Первая — временная: root от начала принятия операции до отправки результата. Вторая — экспериментальная: одинаковые cohort, число запросов, concurrency, входные данные, версия приложения и правило отбора trace. Без второй границы короткий ответ после изменения может быть следствием меньшей нагрузки, попадания в кэш или другого набора данных.
\n| Симптом | Возможное объяснение | Проверка | Действие |
|---|---|---|---|
| CPU низкий, root медленный | Запрос ждёт очередь, пул соединений или внешний ответ | Сопоставить root с дочерними span-ами и метриками ожидания | Назвать покрытый участок; неизвестный остаток оставить unknown |
| Самый длинный span совпадает с пиком latency | Span включает retry или ожидание внутри зависимости | Проверить статус, число попыток, вложенные интервалы и границу сервиса | Не объявлять span причиной без сигнала, который отделяет работу от ожидания |
| После изменения ответ короче | Изменились cohort, concurrency, кэш или форма данных | Сверить контрольные поля и распределение наблюдений | Отменить вывод и повторить с одной нагрузочной границей |
| Дочерний span ссылается на отсутствующего родителя | Потерян экспорт, сломана передача контекста или неверен ID | Проверить полный экспорт, уникальность ID и заголовок traceparent | Остановить причинный вывод и починить наблюдаемость |
| Есть одна удачная запись | Нет пары и распределения, поэтому случай может быть выбросом | Повторить сценарий и сравнить одинаковые квантили либо все наблюдения | Называть это наблюдением, а не эффектом изменения |
Ниже приведена полностью синтетическая запись. Числа условны, код не обращается к сети, базе, часам или профайлеру. Он проверяет только связность дерева и корректность локальных интервалов. Название observation-ready означает «запись можно читать», а не «причина найдена».
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 — причина продолжить сбор данных, а не разрешение выбрать удобного виновника.
Дочерние операции могут идти последовательно или параллельно. В последовательном пути суммарная длительность часто близка к root за вычетом неразмеченных участков. В параллельном пути сумма дочерних span-ов может быть больше root. Поэтому сложение всех длительностей не даёт автоматически критический путь.
\nДля критического пути нужно увидеть порядок зависимостей и момент, когда root действительно мог завершиться. Если два вызова стартовали рядом и один ждал другой только на стадии сборки ответа, их интервалы нельзя трактовать как две последовательные секунды. Полезнее построить waterfall: начало и конец каждого span-а, parent, зависимость и участок ожидания. Если система не экспортирует такие данные, вывод ограничивается известными границами.
\nТакже не смешивайте клиентскую и серверную задержку. Время до отправки HTTP-запроса, очередь на прокси, обработка в приложении и чтение ответа — разные участки. W3C Trace Context помогает передать идентификатор между границами, но сам заголовок не создаёт отсутствующие span-ы и не гарантирует, что каждый посредник сохранит запись. Для каждого разрыва нужен отдельный способ проверки.
\nЕсли отдельный сигнал подтвердил ожидание в пуле, действие может быть локальным: проверить размер пула, время выдачи соединения и конкуренцию. Увеличивать пул без измерения опасно: можно перенести очередь в базу и поднять число одновременных запросов. Если подтверждён внешний вызов, сравните timeout, retry и кэширование, но не принимайте рост timeout за улучшение — пользователь может ждать дольше.
\nЕсли подтверждена локальная работа CPU, тогда уместен профайлер или измерение конкретного участка. Если trace показывает только неизвестный остаток, сначала улучшите наблюдаемость. Выбор действия определяется границей доказательства: исправлять компонент, который виден на схеме, но не подтверждён сигналом, — дорогая гипотеза.
\nРезультат эксперимента должен включать baseline, контрольные поля, число наблюдений, метрику сравнения и побочный эффект. Фраза «стало быстрее» слишком коротка для воспроизводимого решения. Точнее: «на одинаковом наборе из 12 запросов при concurrency 3 медиана root изменилась с X до Y; число ошибок и таймаутов не выросло; p95 не проверялся». Если X и Y не измерены, так и напишите.
\nЭта схема подходит для запросов, где можно получить сопоставимые временные интервалы и контекст нагрузки. Она не заменяет нагрузочный тест, профилирование, анализ блокировок или проверку пользовательского устройства. Одна трасса не показывает поведение хвоста распределения, стоимость соединений, throughput и эффект кэша.
\nSampling может исключить нужный trace или span. Collector может потерять событие, а асинхронный worker — продолжить работу после завершения root. Повторные попытки создают несколько похожих операций. Часы разных узлов могут расходиться, поэтому абсолютное положение соседних интервалов требует осторожности. Эти условия не запрещают анализ, но снижают силу вывода и должны попасть в запись расследования.
\nНельзя переносить условные числа из примера в SLA. RFC 9110 описывает семантику HTTP-запросов и ответов, но не устанавливает бюджет времени конкретного приложения. Бюджет latency, допустимый процент ошибок и окно сравнения задаёт сама система вместе с её требованиями. Если таких требований нет, сначала согласуйте критерий, иначе эксперимент не имеет точки принятия решения.
\nРасследование можно передавать следующему инженеру, если он получает исходный симптом, trace, контрольную границу и список пропусков. Он должен найти root, проверить parent/child-связи, отличить названный интервал от unknown, повторить отрицательный путь и понять, какое наблюдение подтвердит или опровергнет следующую гипотезу.
\nИзмеренный эффект допустимо объявлять только при сопоставимых записях до и после, одном изменённом факторе, одинаковом способе измерения и проверенных ошибках и таймаутах. Иначе итог формулируется скромнее: «найден участок для дальнейшей проверки» или «данных недостаточно». Такая граница сохраняет время команды и не маскирует отсутствие причинного доказательства.
\ntraceparent и tracestate для передачи контекста; наличие заголовка не гарантирует полноту сбора.