diff --git a/editorial/agent-rewrites/029.json b/editorial/agent-rewrites/029.json index 65b2e52..e7496b3 100644 --- a/editorial/agent-rewrites/029.json +++ b/editorial/agent-rewrites/029.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-03-mechanism-d-lessons", "title": "D и C API: как закрыть небезопасную границу буфера", "excerpt": "Как проверить pointer, length и lifetime до вызова C, изолировать @trusted и не принять атрибут @safe за доказательство всей системы.", - "contentHtml": "
C-функция получает указатель и длину буфера. Указатель не несёт длину сам. Если длина пришла из заголовка пакета, а буфер содержит меньше байт, C прочитает за его пределами. Симптомы плавают: редкое падение, испорченный результат, ошибка только на одной нагрузке или незаметное повреждение памяти. Цена ошибки — потеря данных, аварийное завершение процесса и уязвимость, которую трудно связать с исходным запросом.
\nТезис: безопасная граница D/C строится не атрибутом на имени функции. Нужны проверенный диапазон, ясный владелец памяти, известное время жизни и маленький участок, где компилятор не может проверить внешний контракт. В D этот участок обычно помечают @trusted. Наружу он должен отдавать интерфейс, который можно вызывать из @safe кода.
Рассмотрим условный C API. Он принимает адрес, число байт и возвращает код. C доверяет вызывающему. Он не знает capacity исходного массива и не может проверить, что length соответствует выделенной памяти. D тоже не восстановит этот факт из одного raw pointer.
extern(C) int decode_packet(const(ubyte)* data, size_t length);\n\n// Учебный пример: здесь нет реальной библиотеки и production-данных.\nint call_decoder(const(ubyte)[] input) {\n return decode_packet(input.ptr, input.length);\n}\nСам вызов выглядит убедительно, но контракт неполон. Нужно знать, читает ли функция ровно length байт, ожидает ли завершающий ноль, сохраняет ли указатель после возврата и кто освобождает возвращённую память. Если C сохраняет адрес, передача временного массива становится ошибкой lifetime. Если формат требует заголовок фиксированного размера, проверка только верхней границы не подтверждает корректность пакета.
@safe ограничивает набор операций, которые могут привести к повреждению памяти. Это обещание относится к проверяемому D-коду и его интерфейсу. Оно не проверяет реализацию неизвестной C-библиотеки, её ABI, размер структуры или смысл поля.
@system разрешает низкоуровневые операции. Такой код может выполнять арифметику указателей и другие действия, которые требуют ручного доказательства. @trusted сохраняет эти возможности внутри тела, но разрешает вызов из безопасного кода. Поэтому @trusted — не знак «компилятор проверил». Это ручное обещание автора. Чем больше тело trusted-функции, тем больше непроверенных предположений в одном месте.
Узкий wrapper должен принимать сильное представление входа. Slice D связывает адрес и длину. Но slice не знает, соблюдает ли внешний API null termination, не освобождает ли C память во время вызова и не сохраняет ли адрес. Эти условия остаются частью контракта библиотеки.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Падение на длинном пакете | Заявленная длина больше capacity | Сравнить длину с размером slice до вызова | Остановить вызов и вернуть ошибку формата |
| Успешный вызов, затем сбой | C сохранила указатель на временный буфер | Прочитать ownership и проверить escape адреса | Запретить сохранение или передать копию |
| Ошибка только на C-строке | Нет null terminator | Проверить завершающий байт и длину строки | Добавить terminator либо вызвать byte API |
| Работает в одном target | Не совпали ABI, alignment или layout | Сверить header, calling convention и размеры типов | Зафиксировать ABI-тест для каждого target |
| Функция помечена @trusted, но меняет всё | В wrapper спрятали парсинг и бизнес-логику | Посчитать обязанности и raw операции в теле | Оставить только boundary check и вызов C |
Проверка должна отвечать на разные вопросы отдельно. Сначала адрес принадлежит живому объекту. Затем длина целая, неотрицательная и не выходит за capacity. Потом проверяется формат: минимальный размер, terminator, допустимый enum или версия. Только после этого вызывается C-функция. Один boolean с именем isValid скрывает слишком много условий и плохо объясняет отказ.
Ниже учебный checker. Он не анализирует D-память и не вызывает библиотеку. Он показывает отрицательный путь: неизвестный владелец и длина за пределами capacity не превращаются в safe-interface. Числа нужны только для иллюстрации правил.
struct BoundaryResult {\n string kind;\n string action;\n}\n\nBoundaryResult checkBoundary(size_t capacity, size_t length, bool pointerChecked) {\n if (!pointerChecked) {\n return BoundaryResult(`system`, `проверить указатель и владельца`);\n }\n if (length > capacity) {\n return BoundaryResult(`reject`, `остановить вызов до C`);\n }\n return BoundaryResult(`safe-interface`, `передать проверенный slice`);\n}\n\n// Учебные входы: 16/8 -> safe-interface; 16/24 -> reject.\n// 16/8 без проверки указателя -> system.\nРеальный wrapper должен учитывать переполнение при вычислении диапазона, нулевую длину, alignment, null pointer, правила потока и возвращаемый код ошибки. Если функция принимает offset + length, нельзя сначала сложить значения без проверки переполнения. Сравнение через length <= capacity - offset безопаснее, если сначала доказано, что offset <= capacity.
Параметр scope помогает выразить, что функция не должна сохранять ссылку на переданный объект. Но атрибут не переписывает документацию C. Если библиотека кладёт адрес в глобальное состояние или использует его в другом потоке, wrapper должен запретить такой сценарий или передать отдельную копию с явным владельцем.
Возвращаемый raw pointer создаёт обратную задачу. До преобразования в D slice нужно знать размер объекта и способ освобождения. Если размер неизвестен, безопасного представления нет. Если C требует специальную функцию освобождения, вызов free из D неверен. Копирование в D-буфер часто увеличивает стоимость, но даёт ясный lifetime. Это инженерный trade-off, а не деталь синтаксиса.
Не объединяйте в @trusted чтение файла, разбор формата, бизнес-правила и FFI. Тогда тест на один указатель не покрывает остальные решения. Пусть trusted-тело делает одну вещь: проверяет инвариант, формирует вызов и возвращает результат с понятным ownership.
@trusted-тело.@safe только после доказательства интерфейса. Неясную или непроверяемую ветку пометить @system и запретить случайный вызов.Memory safety не означает переносимость, корректный порядок байтов, отсутствие логической ошибки или правильный ABI. @safe не делает C-библиотеку безопасной. @trusted не создаёт доказательство автоматически. Даже верная проверка capacity не замечает неверный enum, неправильную версию структуры или гонку за буфер.
Если C API сохраняет входной адрес, простой вызов с borrowed slice нельзя считать готовым. Если невозможно установить размер возвращённого объекта, нужно копирование, дополнительный API или отказ от интеграции. Если target изменяет layout, один зелёный тест на локальной машине ничего не доказывает. Если неясно, кто освобождает память, не передавайте владение через границу.
\nКод checker выше не даёт production-результата. Он не видит aliasing, реальный lifetime, alignment и calling convention. Его можно использовать только как учебную форму таблицы решений. Доказательство создают контракт библиотеки, тесты на реальном ABI и наблюдаемое поведение сборки продукта.
\nГраница готова, если другой инженер может по документации и коду ответить на пять вопросов: какой диапазон читается, кто владеет памятью, может ли адрес пережить вызов, какой ABI используется и как сообщается ошибка. Для каждого вопроса есть тест или явное стоп-условие. Невалидная длина не достигает C-вызова. Возвращаемый буфер освобождается тем способом, который требует библиотека.
\nДополнительная проверка должна проходить на всех target-платформах продукта. Успешный тест недостаточен: нужен тест, который намеренно нарушает длину, lifetime и формат и получает контролируемый отказ. Только после этого @safe на внешней функции описывает проверенный интерфейс, а не надежду на реализацию C.
@safe, @trusted, @system, роль scope и ограничения memory safety.C-функция получает указатель и длину буфера. Указатель не несёт длину сам. Если длина пришла из заголовка пакета, а буфер содержит меньше байт, C прочитает за его пределами. Симптомы плавают: редкое падение, испорченный результат, ошибка только на одной нагрузке или незаметное повреждение памяти. Цена ошибки — потеря данных, аварийное завершение процесса и уязвимость, которую трудно связать с исходным запросом.
\nТезис: безопасная граница D/C строится не атрибутом на имени функции. Нужны проверенный диапазон, ясный владелец памяти, известное время жизни и маленький участок, где компилятор не может проверить внешний контракт. В D этот участок обычно помечают @trusted. Наружу он должен отдавать интерфейс, который можно вызывать из @safe кода.
Рассмотрим условный C API. Он принимает адрес, число байт и возвращает код. C доверяет вызывающему. Он не знает capacity исходного массива и не может проверить, что length соответствует выделенной памяти. D тоже не восстановит этот факт из одного raw pointer.
extern(C) @system int decode_packet(\n const(ubyte)* data,\n size_t length\n);\n\nint call_decoder(scope const(ubyte)[] input) @trusted {\n enum headerSize = 4;\n if (input.length < headerSize)\n return -1; // формат отклонён до перехода в C\n\n // Доказать отдельно: C читает только length байт\n // и не сохраняет input.ptr после возврата.\n return decode_packet(input.ptr, input.length);\n}\nСам вызов выглядит убедительно, но контракт неполон. Нужно знать, читает ли функция ровно length байт, ожидает ли завершающий ноль, сохраняет ли указатель после возврата и кто освобождает возвращённую память. Если C сохраняет адрес, передача временного массива становится ошибкой lifetime. Если формат требует заголовок фиксированного размера, проверка только верхней границы не подтверждает корректность пакета.
@safe ограничивает набор операций, которые могут привести к повреждению памяти. Это обещание относится к проверяемому D-коду и его интерфейсу. Оно не проверяет реализацию неизвестной C-библиотеки, её ABI, размер структуры или смысл поля.
@system разрешает низкоуровневые операции. Такой код может выполнять арифметику указателей и другие действия, которые требуют ручного доказательства. @trusted сохраняет эти возможности внутри тела, но разрешает вызов из безопасного кода. Поэтому @trusted — не знак «компилятор проверил». Это ручное обещание автора. Чем больше тело trusted-функции, тем больше непроверенных предположений в одном месте.
Узкий wrapper должен принимать сильное представление входа. Slice D связывает адрес и длину. Но slice не знает, соблюдает ли внешний API null termination, не освобождает ли C память во время вызова и не сохраняет ли адрес. Эти условия остаются частью контракта библиотеки.
Практический критерий для такого wrapper простой: если функция объявлена @trusted, рядом должны быть названы все условия, при которых вызов C определён. В этом примере их два: диапазон ограничен переданной длиной, а указатель используется только до возврата. Не переносите эти условия в комментарий «на всякий случай»: закрепите их тестом или ссылкой на header. Если доказать условие нельзя, граница остаётся @system.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Падение на длинном пакете | Заявленная длина больше capacity | Сравнить длину с размером slice до вызова | Остановить вызов и вернуть ошибку формата |
| Успешный вызов, затем сбой | C сохранила указатель на временный буфер | Прочитать ownership и проверить escape адреса | Запретить сохранение или передать копию |
| Ошибка только на C-строке | Нет null terminator | Проверить завершающий байт и длину строки | Добавить terminator либо вызвать byte API |
| Работает в одном target | Не совпали ABI, alignment или layout | Сверить header, calling convention и размеры типов | Зафиксировать ABI-тест для каждого target |
| Функция помечена @trusted, но меняет всё | В wrapper спрятали парсинг и бизнес-логику | Посчитать обязанности и raw операции в теле | Оставить только boundary check и вызов C |
Проверка должна отвечать на разные вопросы отдельно. Сначала адрес принадлежит живому объекту. Затем длина целая, неотрицательная и не выходит за capacity. Потом проверяется формат: минимальный размер, terminator, допустимый enum или версия. Только после этого вызывается C-функция. Один boolean с именем isValid скрывает слишком много условий и плохо объясняет отказ.
Ниже учебный checker. Он не анализирует D-память и не вызывает библиотеку. Он показывает отрицательный путь: неизвестный владелец и длина за пределами capacity не превращаются в safe-interface. Числа нужны только для иллюстрации правил.
struct BoundaryResult {\n string kind;\n string action;\n}\n\nBoundaryResult checkBoundary(size_t capacity, size_t length, bool pointerChecked) {\n if (!pointerChecked) {\n return BoundaryResult(`system`, `проверить указатель и владельца`);\n }\n if (length > capacity) {\n return BoundaryResult(`reject`, `остановить вызов до C`);\n }\n return BoundaryResult(`safe-interface`, `передать проверенный slice`);\n}\n\n// Учебные входы: 16/8 -> safe-interface; 16/24 -> reject.\n// 16/8 без проверки указателя -> system.\nРеальный wrapper должен учитывать переполнение при вычислении диапазона, нулевую длину, alignment, null pointer, правила потока и возвращаемый код ошибки. Если функция принимает offset + length, нельзя сначала сложить значения без проверки переполнения. Сравнение через length <= capacity - offset безопаснее, если сначала доказано, что offset <= capacity.
Параметр scope помогает выразить, что функция не должна сохранять ссылку на переданный объект. Но атрибут не переписывает документацию C. Если библиотека кладёт адрес в глобальное состояние или использует его в другом потоке, wrapper должен запретить такой сценарий или передать отдельную копию с явным владельцем.
Возвращаемый raw pointer создаёт обратную задачу. До преобразования в D slice нужно знать размер объекта и способ освобождения. Если размер неизвестен, безопасного представления нет. Если C требует специальную функцию освобождения, вызов free из D неверен. Копирование в D-буфер часто увеличивает стоимость, но даёт ясный lifetime. Это инженерный trade-off, а не деталь синтаксиса.
Не объединяйте в @trusted чтение файла, разбор формата, бизнес-правила и FFI. Тогда тест на один указатель не покрывает остальные решения. Пусть trusted-тело делает одну вещь: проверяет инвариант, формирует вызов и возвращает результат с понятным ownership.
@trusted-тело.@safe только после доказательства интерфейса. Неясную или непроверяемую ветку пометить @system и запретить случайный вызов.Memory safety не означает переносимость, корректный порядок байтов, отсутствие логической ошибки или правильный ABI. @safe не делает C-библиотеку безопасной. @trusted не создаёт доказательство автоматически. Даже верная проверка capacity не замечает неверный enum, неправильную версию структуры или гонку за буфер.
Если C API сохраняет входной адрес, простой вызов с borrowed slice нельзя считать готовым. Если невозможно установить размер возвращённого объекта, нужно копирование, дополнительный API или отказ от интеграции. Если target изменяет layout, один зелёный тест на локальной машине ничего не доказывает. Если неясно, кто освобождает память, не передавайте владение через границу.
\nКод checker выше не даёт production-результата. Он не видит aliasing, реальный lifetime, alignment и calling convention. Его можно использовать только как учебную форму таблицы решений. Доказательство создают контракт библиотеки, тесты на реальном ABI и наблюдаемое поведение сборки продукта.
\nГраница готова, если другой инженер может по документации и коду ответить на пять вопросов: какой диапазон читается, кто владеет памятью, может ли адрес пережить вызов, какой ABI используется и как сообщается ошибка. Для каждого вопроса есть тест или явное стоп-условие. Невалидная длина не достигает C-вызова. Возвращаемый буфер освобождается тем способом, который требует библиотека.
\nДополнительная проверка должна проходить на всех target-платформах продукта. Успешный тест недостаточен: нужен тест, который намеренно нарушает длину, lifetime и формат и получает контролируемый отказ. Только после этого @safe на внешней функции описывает проверенный интерфейс, а не надежду на реализацию C.
@safe, @trusted, @system, роль scope и ограничения memory safety.Утилита запускается медленно, занимает больше памяти, чем ожидалось, или требует вызова C-библиотеки. Команда сразу предлагает переписать её на D: язык компилируется в native binary, умеет работать с C ABI и даёт контроль над памятью. Но симптом ещё не показывает причину. Задержку может создавать сеть, формат файла, лишние копии или неверная граница API. Цена ошибочного выбора — новый компилятор, сборочный pipeline, обучение и месяцы поддержки без исправления узкого места.
\nТезис простой: D стоит проверять не по списку свойств языка, а по контракту задачи. Сначала зафиксируйте workload и бюджет, затем найдите участок, который действительно изменится при смене языка. После этого сравните D с текущим инструментом по измеримому эффекту и полной стоимости доставки. Если доказательства не складываются, решение остаться на текущем языке будет корректным результатом.
\nФраза «нужна производительность» не задаёт задачи. Для CLI важны время запуска, время обработки одного входа, пиковая память и размер бинарника. Для фонового процесса важны throughput, steady-state latency и поведение после нескольких часов работы. Для сервиса добавляются конкуренция, timeout и наблюдаемость. Для вызова C-библиотеки важны layout структуры, calling convention, ownership указателей и код ошибки.
\nЗапишите один сценарий, а не среднее впечатление. Например: «утилита читает 2 ГБ логов, должна обработать файл менее чем за 20 секунд, запускается на Linux x86_64 и arm64, а парсер отдаёт данные в C-библиотеку». В таком описании уже видны единица нагрузки, предел времени, targets и native boundary. Без них benchmark легко превращается в сравнение несопоставимых программ.
\nУ решения есть четыре связанные части. Workload показывает, сколько данных и операций проходит через код. Бюджет latency задаёт допустимую цену одной операции. Native boundary показывает, нужен ли прямой доступ к C, системному вызову или нативному формату. Target matrix показывает, сколько раз придётся собрать, протестировать и доставить бинарник.
\nD может быть сильным кандидатом, когда горячий участок вычисляет данные локально, нужен native deployment или уже есть C ABI. Но это только основание для эксперимента. У D остаются стоимость компилятора и зависимостей, различия runtime, диагностика бинарника, упаковка под несколько архитектур и время команды. Нативный бинарник не отменяет сетевую задержку и не делает внешний API безопасным.
\nКонтрактные проверки полезны внутри функции. Precondition проверяет входной инвариант, postcondition — свойство результата. Они не заменяют проверку пользовательского файла, обработку ошибки и тесты. Проверка должна принадлежать тому уровню, который владеет условием: parser проверяет формат, доменный код — смысл, wrapper — указатель, длину и время жизни.
\n| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| Долгий запуск | Импорт модулей, чтение конфигурации, сеть | Профиль cold start с отключённой сетью | Исправить инициализацию; язык менять только при доказанном CPU-узком месте |
| Медленная обработка файла | Копии строк, декодирование, неверный алгоритм | Профиль CPU и аллокаций на одном входе | Сравнить алгоритм и парный прототип D |
| Рост RSS | Долгоживущие ссылки, кэш, фрагментация | Снять профиль памяти по этапам batch | Укоротить lifetime; не отключать GC по одному графику |
| Падение в C-вызове | Неверная длина, layout или ownership | Сверить header, размер, offset и код возврата | Изолировать wrapper и остановить вызов при несовпадении |
| Сложная доставка | Несколько архитектур и ручная упаковка | Собрать чистые артефакты для каждого target | Сравнить цену toolchain с выигрышем runtime |
Следующая функция не измеряет скорость и не выбирает язык автоматически. Она превращает карточку задачи в явные условия. Числа учебные: throughput 12 000 и бюджет 20 мс нельзя переносить на другое железо. В реальном решении их заменяют измерениями одного workload.
\nfunction decideD(input) {\n const throughput = Number(input.throughput);\n const latencyBudgetMs = Number(input.latencyBudgetMs);\n const targets = Number(input.deploymentTargets);\n if (!Number.isFinite(throughput) || throughput <= 0) return { decision: 'reject', reason: 'нет измеримой нагрузки' };\n if (latencyBudgetMs <= 0) return { decision: 'reject', reason: 'нет бюджета задержки' };\n if (targets > 2 && !input.nativeBoundary) return { decision: 'compare', reason: 'сначала сравнить toolchain' };\n if (input.nativeBoundary && throughput > 10000) return { decision: 'prototype-d', reason: 'есть основание для парного прототипа' };\n return { decision: 'keep-current-tool', reason: 'смена языка не обоснована' };\n}\n\nconsole.log(decideD({ throughput: 12000, latencyBudgetMs: 20, nativeBoundary: true, deploymentTargets: 1 }));\n// Учебный результат: { decision: 'prototype-d', ... }\nПоложительная ветка означает только «собрать прототип». Она не означает «переписать продукт». Ветка keep-current-tool нужна намеренно: если нагрузка мала, границы с native-кодом нет, а текущий стек уже покрывает доставку, новый язык увеличит риск без доказанной пользы. Ветка compare останавливает преждевременный выбор при широкой матрице targets.
Указатель и длина образуют один контракт. Сам указатель не сообщает, сколько байт можно читать. Wrapper должен получить буфер, проверить его владельца и диапазон, а затем передать в C только проверенный slice или пару pointer/length. Если библиотека сохраняет адрес после возврата, обычного временного буфера недостаточно: нужен согласованный lifetime или копия.
\nСтруктуру тоже нельзя считать совместимой по имени полей. Сверьте размер, offsets, alignment, порядок байтов и calling convention. Отдельно зафиксируйте значения кода ошибки. Частично заполненный output не равен успешному результату. Сначала проверьте код возврата, затем версию и layout, потом отдайте значение доменному коду.
\nФильтр не заменяет profiler, benchmark и review ABI. Его пороги вымышлены и нужны только для формы проверки. Даже хороший benchmark не переносит результат на другую архитектуру, версию компилятора, размер данных или режим нагрузки. Нативная сборка не гарантирует меньшую память. Контракт не исправляет неверную бизнес-логику.
\nОценка должна учитывать отрицательный путь. Если wrapper не может доказать длину, lifetime или layout, вызов нужно остановить. Если D выигрывает только в искусственном микротесте, но требует отдельной упаковки и дежурства, выигрыш не доказан. Если текущий язык после устранения лишних копий укладывается в бюджет, миграция не нужна.
\nРешение готово, когда для одного и того же workload есть повторяемые замеры текущего инструмента и прототипа D, описаны targets и native boundary, а также измерена цена сборки и поддержки. Для каждого результата указаны вход, версия toolchain, архитектура, число повторов и критерий успеха. Вызов C проходит только после проверки размера, lifetime и кода ошибки. Команда может объяснить не только почему D быстрее, но и почему это преимущество покрывает стоимость доставки.
\n@safe, @trusted и @system.Утилита запускается медленно, занимает больше памяти, чем ожидалось, или требует вызова C-библиотеки. Команда сразу предлагает переписать её на D: язык компилируется в native binary, статически типизирован и умеет работать с C ABI. Но набор свойств ещё не объясняет симптом. Задержку может создавать сеть, формат файла, лишние копии или неверная граница API. Цена ошибочного перехода — новый компилятор, сборочный pipeline, обучение и месяцы поддержки без исправления узкого места.
\nРазберём лабораторный сценарий: CLI читает поток логов, считает строки и выделяет записи с ERROR. Для него задан бюджет — обработать один и тот же вход не дольше 20 секунд, а пиковая память должна остаться ниже 512 МБ. Числа и программа учебные; они не являются результатом замера чужой системы. Задача статьи — дать процедуру, по которой можно получить собственные данные и решить, оправдан ли прототип на D.
Фраза «нужна производительность» не задаёт инженерной задачи. Для CLI нужны время холодного старта, время обработки фиксированного входа, throughput (объём данных в секунду), пиковая память и размер артефакта. Для фонового процесса добавляются длительность работы, задержка отдельных операций и поведение после нескольких часов. Для вызова C-библиотеки важны типы, layout структуры, calling convention, ownership указателей и код ошибки.
\nЗапишите сценарий до эксперимента. Например: «утилита читает 2 ГБ логов через stdin, считает число строк и ошибок, должна завершиться менее чем за 20 секунд на Linux x86_64, результат — два числа в stdout». В описании есть единица нагрузки, граница времени, target и наблюдаемый output. Без этих условий benchmark превращается в сравнение разных программ на разных входах.
\nОфициальная спецификация описывает D как системный язык, который компилируется в native-код, статически типизирован и поддерживает автоматическое и ручное управление памятью. Это делает D разумным кандидатом для локальной CPU-нагрузки, самостоятельного бинарника или уже существующей C-интеграции. Но «кандидат» не означает «готовая замена»: библиотеки, toolchain, диагностика, сборка под архитектуры и сопровождение входят в стоимость решения.
\nУ D есть несколько механизмов, которые влияют на эксперимент. @nogc запрещает функциям прямо или косвенно выполнять операции с GC-кучей, но не превращает всю программу в код без аллокаций. @safe ограничивает часть операций, способных повредить память; @trusted оставляет ручную ответственность внутри маленькой проверенной границы. Контракты in и out выражают предусловия и постусловия, однако их запуск может зависеть от настроек компилятора. Поэтому каждый механизм нужно включить в проверку, а не использовать как рекламное обещание.
При C-вызове объявление extern(C) задаёт согласованную C-связь и последовательность вызова. Оно не угадывает неверный прототип, размер буфера, время жизни указателя или способ освобождения памяти. Даже если функция вызывается, это ещё не доказательство корректности всей границы.
| Симптом | Гипотеза | Проверка | Решение |
|---|---|---|---|
| Долгий запуск | Импорт, конфигурация или сеть, а не CPU | Профиль cold start с отключённой сетью и пустым рабочим набором | Исправить инициализацию; язык менять только при доказанном CPU-узком месте |
| Медленно читается файл | Алгоритм, декодирование или лишняя копия | Сравнить CPU и аллокации на одном входе | Сначала убрать копии; затем сделать парный прототип |
| Растёт RSS | Кэш, удерживаемые ссылки или буферизация всего файла | Замерить память по этапам и размеру входа | Перейти на потоковую обработку; не обещать эффект от native binary |
| Падает C-вызов | Прототип, layout, длина или ownership не совпадают | Сверить header, размеры, offsets, lifetime и код возврата | Изолировать FFI-wrapper и остановить вызов при неизвестном условии |
| Сложно доставлять | Несколько targets, зависимостей и ручных шагов | Собрать чистые артефакты и описать pipeline | Сопоставить стоимость toolchain с измеренным выигрышем |
Таблица задаёт порядок расследования, а не автоматический выбор. Если профиль показывает, что 80% времени занимает чтение сети, переход на D не меняет главную причину. Если потоковая обработка уже укладывается в бюджет, языковая миграция не нужна. Если bottleneck находится в вызове C, полезнее сначала проверить контракт границы, а не переписывать окружающий код.
\nСравнивайте два исполняемых файла, которым подаётся один байтовый вход и которые выдают один логический результат. Зафиксируйте checksum файла, версию исходников, компилятор, флаги, архитектуру, число повторов и способ измерения. Не смешивайте cold start с прогретым процессом. Не сравнивайте debug-сборку текущего инструмента с release-сборкой D.
\nДля учебного CLI создайте D-проект через DUB — официальный build и package manager экосистемы D — и замените содержимое source/app.d таким кодом:
import std.stdio : stdin, writefln;\nimport std.string : indexOf;\n\nvoid main()\n{\n size_t lines;\n size_t errors;\n\n foreach (line; stdin.byLine())\n {\n ++lines;\n if (line.indexOf(\"ERROR\") >= 0)\n ++errors;\n }\n\n writefln(\"%s %s\", lines, errors);\n}\nПрограмма не хранит весь файл в памяти: она читает stdin построчно и печатает только итог. Это свойство нужно подтвердить измерением, а не выводить из синтаксиса. Создайте вход с известным размером и контрольной суммой, затем соберите release-вариант:
\ndub init log-counter --type=application\ncd log-counter\ndub test\ndub build --build=release\nsha256sum ../benchmark/input/logs.txt\n/usr/bin/time -f '%e sec %M KB' ./log-counter < ../benchmark/input/logs.txt > /dev/null\nПоследняя команда использует GNU time и подходит для Linux. На macOS синтаксис системного time отличается, поэтому зафиксируйте другой измеритель, например hyperfine для времени и отдельный инструмент для памяти. Важно не название команды, а одинаковая методика для D и текущей реализации.
Один удачный прогон не доказывает превосходство. Выполните минимум пять повторов после одинаковой подготовки окружения и сохраните минимум, медиану и разброс. Повторите тест для маленького, среднего и предельного входа. Если результат меняется вместе с размером данных, запишите сложность и проверьте, не измеряется ли случайно файловый кеш.
\nПроверяйте корректность раньше скорости. Для каждого входа сравните stdout, код возврата и поведение на повреждённой строке. Затем сравните время, peak RSS, размер бинарника и время сборки с чистого checkout. Если D быстрее, но выдаёт другое число ошибок или не умеет объяснить невалидный UTF-8, это не выигрыш, а несовместимый результат.
\n| Измерение | Что фиксировать | Критерий принятия |
|---|---|---|
| Корректность | stdout, stderr, exit code на тех же входах | Результаты совпадают или различие явно согласовано |
| Runtime | медиана и диапазон пяти повторов | Медиана ниже бюджета и выигрыш не исчезает на предельном входе |
| Память | peak RSS и зависимость от размера файла | Пик не превышает лимит; рост объясним выбранным алгоритмом |
| Доставка | время clean build, размер, targets, зависимости | Команда может повторить сборку без ручного локального состояния |
| Поддержка | сложность отладки, тестов, обновлений и FFI | Есть владелец и понятная процедура изменения |
Критерий должен быть задан до просмотра результатов. Например: «D принимаем в следующий этап, если на трёх размерах входа сохраняется корректность, медиана быстрее текущей реализации минимум на 25%, peak RSS не выходит за лимит, а clean build и cross-compilation укладываются в согласованный pipeline». Порог 25% — проектное решение, а не свойство D. Если хотя бы одно обязательное условие не выполнено, прототип возвращается на разбор или закрывается.
\nNative-интеграция может быть главным аргументом в пользу D, но она же добавляет риск. Указатель и длина образуют пару: адрес сам по себе не сообщает, сколько байт разрешено читать. До вызова wrapper должен проверить диапазон, нулевой адрес, alignment и время жизни буфера. Если C сохраняет адрес после возврата, временный slice нельзя считать достаточным контрактом.
\nСтруктуру нельзя объявлять совместимой по совпадению имён полей. Сверьте размер, offsets, alignment, порядок байтов и calling convention с теми header и compiler flags, которыми собрана библиотека. Если API возвращает указатель, назовите allocator и парную функцию освобождения. Вызов общего free не становится правильным только потому, что он компилируется.
В D выделите короткую границу: она проверяет вход, вызывает C, сначала смотрит return code и только затем читает output. Ошибка должна превращаться в результат, который верхний слой умеет обработать. @trusted полезен как табличка ответственности, но не заменяет проверку ABI и документации библиотеки.
Учебный CLI не моделирует сервис с конкурентными запросами, задержкой сети, большим числом файлов, интерактивным UX или длительным жизненным циклом процесса. Результат на Linux x86_64 нельзя переносить на arm64, Windows, другой компилятор, другую версию runtime или другой размер входа без повторной проверки.
\nПороги 20 секунд, 512 МБ и 25% вымышлены для формы эксперимента. Они не обещают, что D будет быстрее или экономнее. @nogc контролирует GC-аллокации конкретной функции, но не отменяет системный allocator, I/O и сторонние библиотеки. @safe не делает C-код безопасным, а extern(C) не проверяет ownership. Контракты не заменяют тесты на повреждённых входах.
Остановите переход, если workload не воспроизводится, критерий успеха появился после замеров, результат отличается по смыслу, C-граница не описана или clean build требует ручного состояния. Отказ от нового языка — не провал эксперимента. Это экономически полезный результат, если он избавляет команду от миграции, которая не исправляет исходную причину.
\nРешение готово к следующему этапу, когда другой инженер получает фиксированный вход, команды сборки, версии toolchain и скрипт измерения; может повторить проверку на текущей и D-реализации; видит одинаковую корректность, время, память и стоимость доставки; понимает границы ABI и знает стоп-условия. Если этих данных нет, готов только вопрос, а не обоснование перехода.
\n@nogc и атрибуты безопасности; спецификация отдельно оговаривает, что выполнение контрактов зависит от реализации.@safe, @trusted и @system, включая ограничения trusted-кода.extern(C), совместимость типов и ограничения ручной FFI-границы.Симптом появляется после успешного вызова Bitrix API. Пользователь получил новый ID, но телефон остался пустым. Email сохранился в другом регистре. Повторный запуск создал вторую связь с внешней системой. В логах есть только true от CUser::Update, поэтому команда не видит, на каком шаге исчезло значение.
Цена ошибки выше, чем одна неверная строка. По телефону не находится пользователь. Уведомление уходит на старый адрес. Импорт нельзя безопасно повторить. Если исходное значение уже перезаписано, восстановление зависит от резервной копии или ручного поиска.
Тезис: миграция поля готова не тогда, когда API принял запрос. Она готова, когда команда может показать mapping, прочитать сохранённый смысл обратно и повторить тот же вход без дубля или неожиданной очистки.
Legacy-код обычно передаёт массив с именами Bitrix: PERSONAL_PHONE, EMAIL, XML_ID. Новый код хочет получить объект вроде { phone, email, externalId }. Это не простая замена имён. Каждое поле имеет формат, правило пустого значения и обратное представление.
Первая граница — вход. Отсутствующий PERSONAL_PHONE может означать «не менять телефон», а пустая строка — «очистить телефон». Если привести оба состояния к null, адаптер потеряет команду пользователя.
Вторая граница — нормализация. Для телефона допустимы пробелы и разные формы записи, но правило должно быть конкретным. Для email можно привести регистр к нижнему, если это разрешает контракт приложения. Нельзя применять одну функцию ко всем значениям: XML_ID, комментарий и парольный хэш имеют разные правила.
Третья граница — запись. Официальный метод CUser::Update принимает ID и массив полей. Успех означает, что метод не сообщил об ошибке. Он не доказывает, что downstream-обработчик, индекс или внешний обмен увидели ожидаемый смысл.
Четвёртая граница — чтение. После записи нужно получить ту же запись способом, которым её читает приложение. Сравнивайте не только ID и флаг успеха. Сравнивайте поля, внешний идентификатор, состояние пустоты и, если это важно, время изменения.
Начните с небольшой таблицы. В ней видны не только старое и новое имя, но также пустое состояние, источник и проверка результата.
| Поле | Legacy-вход | Каноническое значение | Проверка |
|---|---|---|---|
| Телефон | PERSONAL_PHONE, строка с пробелами | phone, нормализованная строка | read-back и формат |
EMAIL, исходный регистр | email, регистр по правилу контракта | валидность и точное чтение | |
| Связь | XML_ID | externalId | одна запись при retry |
| Не передан | ключ отсутствует | unchanged | старое значение не меняется |
| Очищен | ключ есть, значение пустое | clear | два разных теста |
Если пришёл неизвестный ключ, не угадывайте его назначение по похожему имени. Остановите преобразование и сообщите, какое поле не входит в контракт. Если email не проходит правило приложения, не превращайте его в пустую строку. Верните ошибку до записи.
Следующая функция — изолированный учебный пример. Она не подключается к Bitrix и не обещает результат реального окружения. Её задача — показать, где сохраняется различие между отсутствующим полем и явной очисткой.
function toCanonical(input) {\n const result = {};\n\n if (Object.hasOwn(input, 'PERSONAL_PHONE')) {\n if (input.PERSONAL_PHONE === '') {\n result.phone = { action: 'clear' };\n } else {\n const phone = String(input.PERSONAL_PHONE).trim();\n if (!phone) throw new Error('phone-invalid');\n result.phone = { action: 'set', value: phone };\n }\n }\n\n if (Object.hasOwn(input, 'EMAIL')) {\n const email = String(input.EMAIL).trim().toLowerCase();\n if (!email.includes('@')) throw new Error('email-invalid');\n result.email = { action: 'set', value: email };\n }\n\n if (Object.hasOwn(input, 'XML_ID')) {\n result.externalId = { action: 'set', value: String(input.XML_ID) };\n }\n\n return result;\n}\n\nconst value = toCanonical({\n PERSONAL_PHONE: ' +7 900 000-00-00 ',\n EMAIL: 'User@Example.TEST',\n XML_ID: 'crm-17',\n});\n\n// phone: set '+7 900 000-00-00'\n// email: set 'user@example.test'\n// externalId: set 'crm-17'Для учебного входа функция удаляет внешние пробелы, приводит email к нижнему регистру и оставляет связь как строку. Это выбранные правила примера, а не универсальная политика Bitrix. В реальном проекте их нужно заменить правилами доменного контракта.
Адаптер записи строится после такой проверки. Для unchanged поле не добавляется. Для clear передаётся явное значение очистки, согласованное с API и проектом.
$fields = [];\n\nif ($canonical['phone']['action'] === 'set') {\n $fields['PERSONAL_PHONE'] = $canonical['phone']['value'];\n}\n\nif ($canonical['phone']['action'] === 'clear') {\n $fields['PERSONAL_PHONE'] = '';\n}\n\nif ($canonical['email']['action'] === 'set') {\n $fields['EMAIL'] = $canonical['email']['value'];\n}\n\n$user = new CUser;\nif (!$user->Update($userId, $fields)) {\n throw new RuntimeException($user->LAST_ERROR);\n}Код показывает только форму вызова. Он не проверяет права, события, пользовательские поля и транзакцию. Перед использованием нужно подтвердить, что модуль и нужная поверхность API доступны в конкретной установке. Для D7 и legacy-пути нельзя считать классы взаимозаменяемыми без отдельного mapping.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| API вернул успех, поле пустое | Неверное имя, формат или обработчик изменил значение | Сравнить mapping, raw-вход и read-back | Исправить границу и повторить на одной записи |
| Повторный запуск создаёт дубль | Нет стабильного внешнего ключа или retry неидемпотентен | Повторить вход по XML_ID и проверить количество записей | Закрепить ключ операции и запретить создание без него |
| Поле исчезает при частичном обновлении | Missing и clear сведены к одному значению | Проверить отсутствие ключа и пустую строку отдельно | Передавать очистку только явной командой |
| Один сервер принимает вызов, другой — нет | Модуль не подключён или поверхности API различаются | Проверить IncludeModule, класс и метод в целевой среде | Остановить миграцию и выбрать совместимый adapter |
| Email стал недействительным | Нормализация скрыла ошибку или правило шире контракта | Проверить валидатор до записи и значение после чтения | Вернуть ошибку, не записывать пустой заменитель |
missing, clear, invalid и unchanged.Документация Bitrix описывает публичный API, но не знает локальные события, права, пользовательские поля, обработчики и SQL-ограничения проекта. Успешный вызов в одной установке не доказывает совместимость другой. Версия ядра помогает сузить поиск, но не заменяет проверку фактической поверхности.
Read-back может отличаться от входа по допустимому правилу сервера. Телефон может получить другой формат. Время изменения зависит от среды. Такие расхождения нужно разделить на ожидаемую нормализацию и потерю смысла. Нельзя объявлять их одинаковыми только потому, что совпал ID.
Учебный JavaScript-пример не является production-валидатором. Проверка email.includes('@') намеренно упрощена. PHP-функция filter_var может быть частью технической проверки email, но она не определяет бизнес-правила, разрешённые домены и требуемое поведение пустого поля.
Если read-back невозможен, миграция не получает доказательство сохранения. В этом случае не расширяйте объём. Сначала добавьте безопасный способ проверить запись или оставьте адаптер за границей массового запуска. Отрицательное решение лучше тихой потери данных.
Одна миграция поля готова, если другой инженер может восстановить вход и получить тот же результат: mapping объясняет каждое переданное поле, missing и clear различаются, API-поверхность подтверждена, read-back совпадает по смысловым значениям, а повторный запуск не создаёт дубль.
Для отрицательных случаев есть отдельные доказательства: неизвестное поле останавливается, неверный email не превращается в пустую строку, отсутствие поля не очищает старое значение, недоступный модуль не приводит к частичному запуску. Только после этих проверок можно говорить о расширении на следующую выборку.
LAST_ERROR. Страница не описывает локальные события и mapping проекта.ID, XML_ID и PERSONAL_PHONE, а также связь с D7-представлением. Это описание API, не подтверждение состояния установки.Сбой обнаруживается после внешне успешной миграции. Bitrix вернул true, но телефон оказался пустым, email изменил регистр, а повторный запуск создал вторую связь. Такая ошибка стоит дороже одной неверной строки: поиск пользователя перестаёт работать, уведомление уходит не туда, а восстановление исходного значения требует отдельного источника данных.
Первое действие — перестать считать ответ CUser::Update доказательством результата. Метод сообщает, что вызов прошёл без собственной ошибки, но не подтверждает смысл сохранённых данных, работу локальных обработчиков или результат последующего чтения. Миграция готова только тогда, когда mapping понятен, запись прочитана обратно, а повтор того же входа не меняет результат неожиданно.
В Bitrix нужно разделить стандартные поля пользователя и пользовательские поля. К первым относятся, например, EMAIL и PERSONAL_PHONE. Пользовательские поля обычно имеют имена UF_*, а их конкретные коды, типы и множественные значения задаёт сама установка. Поэтому имя из одной базы нельзя переносить в другую только по совпадению текста.
Есть и третье различие — действие над полем. Отсутствующий ключ означает «не менять», явное пустое значение может означать «очистить», а некорректное значение должно остановить операцию до записи. Если adapter превращает все три состояния в null, он теряет часть команды источника и может стереть данные при частичном обновлении.
Сначала составьте карту полей для одной целевой записи. В ней зафиксируйте не только имена, но и тип, действие при пустом значении, правило нормализации и способ проверки. Имя UF_LEGACY_ID ниже — пример строкового пользовательского поля; в реальном проекте его нужно заменить кодом, который существует в целевой установке.
| Источник | Цель Bitrix | Правило | Доказательство |
|---|---|---|---|
phone | PERSONAL_PHONE | обрезать внешние пробелы; пустое значение не скрывать | read-back и проверка формата |
email | EMAIL | применить только правило доменного контракта | точное чтение и отрицательный тест |
legacyId | UF_LEGACY_ID | проверить тип и уникальность в этой установке | поиск по стабильному ключу |
| ключ отсутствует | поле не включать в update | старое значение сохраняется | тест partial update |
| ключ есть, значение пустое | явная очистка по контракту | не смешивать с missing | отдельный тест clear |
Такая карта отвечает на вопрос, который обычно теряется в legacy-коде: что должен сделать adapter, если поле пришло не в идеальной форме. Для списка, файла или множественного пользовательского поля нельзя автоматически использовать правило обычной строки. Сначала прочитайте тип поля в целевой установке и зафиксируйте ожидаемое представление.
Нормализуйте данные до вызова API и возвращайте не только значение, но и действие. В учебном JavaScript-примере ниже отсутствующий ключ вообще не попадает в результат, пустая строка становится явным clear, а очевидно неверный email отклоняется. Это граница примера, а не универсальный валидатор почты.
function toCanonical(input) { const result = {}; if (Object.hasOwn(input, 'phone')) { if (input.phone === '') { result.phone = { action: 'clear' }; } else if (typeof input.phone !== 'string') { throw new Error('phone-type'); } else { const phone = input.phone.trim(); if (!phone) throw new Error('phone-invalid'); result.phone = { action: 'set', value: phone }; } } if (Object.hasOwn(input, 'email')) { if (input.email === '') { result.email = { action: 'clear' }; } else if (typeof input.email !== 'string') { throw new Error('email-type'); } else { const email = input.email.trim().toLowerCase(); if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(email)) throw new Error('email-invalid'); result.email = { action: 'set', value: email }; } } if (Object.hasOwn(input, 'legacyId')) { if (input.legacyId === '') { result.legacyId = { action: 'clear' }; } else { const legacyId = String(input.legacyId).trim(); if (!legacyId) throw new Error('legacy-id-invalid'); result.legacyId = { action: 'set', value: legacyId }; } } return result; } const canonical = toCanonical({ phone: ' +7 900 000-00-00 ', email: 'User@Example.TEST', legacyId: 'crm-17' }); // phone: set '+7 900 000-00-00' // email: set 'user@example.test' // legacyId: set 'crm-17'Пример намеренно не угадывает формат телефона и не объявляет проверку email полной. Нижний регистр email может быть правилом вашего домена, но не универсальной гарантией для всех систем. Если бизнес-контракт требует E.164, разрешённый список доменов или сохранение исходного регистра, эти правила должны появиться в отдельной функции и тестах. Нельзя превращать ошибку в пустое значение ради того, чтобы запись прошла.
После нормализации соберите только те поля, для которых есть действие. Для пользовательского поля подставьте фактический UF_*-код и представление, подтверждённое в целевой установке. Метод CUser::Update принимает ID и массив полей; при ошибке он возвращает false и оставляет текст в LAST_ERROR.
$fields = []; if (($canonical['phone']['action'] ?? null) === 'set') { $fields['PERSONAL_PHONE'] = $canonical['phone']['value']; } if (($canonical['phone']['action'] ?? null) === 'clear') { $fields['PERSONAL_PHONE'] = ''; } if (($canonical['email']['action'] ?? null) === 'set') { $fields['EMAIL'] = $canonical['email']['value']; } if (($canonical['legacyId']['action'] ?? null) === 'set') { $fields['UF_LEGACY_ID'] = $canonical['legacyId']['value']; } $user = new CUser; if (!$user->Update($userId, $fields)) { throw new RuntimeException($user->LAST_ERROR); } // Следующий шаг: получить эту же запись приложенческим способом // и сравнить поля, пустые состояния и внешний ключ.Вызов не должен быть последней строкой мигратора. Сохраните безопасный идентификатор операции и перечень изменённых полей, но не кладите телефон, email и другие персональные данные в обычный лог. Затем прочитайте запись тем же слоем, которым её использует приложение. Сравнивайте смысловые значения, а не только ID и флаг успеха.
У официального описания есть важная граница: если пользователя с указанным ID нет, ошибка может не возникнуть. Поэтому read-back должен подтвердить существование целевой записи. Для пользовательского поля также нужно убедиться, что код поля, тип и права относятся именно к целевой установке.
| Симптом | Гипотеза | Проверка | Решение |
|---|---|---|---|
| API вернул успех, значение не читается | неверное имя, тип или локальный обработчик | сравнить mapping, вход и read-back | остановить batch и проверить одну запись |
| Пропуск поля очистил старое значение | missing сведён к clear | повторить partial update без ключа | не включать отсутствующий ключ в $fields |
| Повтор создал дубль | операция создаёт запись вместо поиска по ключу | найти запись по стабильному legacyId | разделить upsert, update и создание |
| В одном окружении работает, в другом нет | разный набор модулей, UF-кодов или прав | проверить поверхность API и схему поля в каждой среде | не расширять запуск до устранения расхождения |
| Email стал пустым после ошибки | валидатор заменил invalid на fallback | проверить отрицательный тест до записи | вернуть ошибку и оставить исходное значение |
Таблица нужна не для классификации задним числом. Она задаёт следующий fetch или тест. Если read-back показывает другой телефон, это ещё не доказывает потерю: сервер мог применить согласованную нормализацию. Если поле исчезло, ищите точку расхождения между исходным ключом, mapping, обработчиком и способом чтения.
UF_*, подтвердите тип, множественность, обязательность и права.set, clear, unchanged или invalid.false сохраните LAST_ERROR и прекратите расширение.CUser::Update обновляет запись по ID, но сам по себе не решает задачу поиска записи по ключу источника и не делает весь процесс миграции идемпотентным. Сначала найдите существующую связь по стабильному ключу, затем решите, допустим ли update. Создание новой записи без проверки ключа — отдельная операция с отдельными правилами и риском дубля.
Перед batch-запуском определите, что делать при частичном успехе. Остановка на первой ошибке проще для контроля, очередь с повтором лучше для большого объёма, а обратная миграция возможна только при сохранённом старом значении. Не называйте процесс обратимым, если вы не храните исходный снимок и не проверили восстановление на тестовой записи.
Документация Bitrix описывает публичный метод и базовые поля, но не знает локальные обработчики событий, права, пользовательские поля, индексы, настройки валидации и формат обмена конкретного проекта. Коды UF_* и их типы нельзя переносить между установками без проверки схемы.
Нормализация телефона и email в статье учебная. Нижний регистр email может быть правилом вашего домена, но не универсальной гарантией для всех систем. Проверка через регулярное выражение не заменяет бизнес-ограничения, подтверждение адреса или проверку уникальности.
Если приложение читает данные из кэша, поискового индекса или внешней копии, немедленный read-back из одного слоя не доказывает, что все потребители уже увидели изменение. Для такой архитектуры добавьте проверку задержки распространения и критерий согласованности. Если read-back невозможен, массовую миграцию продолжать нельзя.
Одна запись считается перенесённой, когда другой инженер может восстановить вход и объяснить каждое изменение: mapping различает стандартное поле и UF_*, missing не очищает значение, invalid останавливает запись, read-back подтверждает смысл, а повтор не создаёт дубль.
Для расширения на batch нужны те же доказательства на отрицательных ветках: неизвестный код поля останавливает запуск, недоступная схема не приводит к частичному обновлению, ошибка API не маскируется пустым fallback, а расхождение read-back классифицируется как ожидаемая нормализация или потеря данных. Если хотя бы один сигнал отсутствует, следующий шаг — собрать его, а не объявлять миграцию успешной.
true/false, LAST_ERROR и оговорка о несуществующем ID.EMAIL, PERSONAL_PHONE, XML_ID, и соответствие классу D7.Ошибка начинается без падения. В документации найден метод, класс подключён, вызов возвращает ID или объект. Но на другой установке модуль не загружен, поле называется иначе, пустая строка означает другое состояние, а обработчик события меняет результат. Симптом появляется позже: форма теряет значение, импорт создаёт дубль, редкая операция падает после обновления. Цена ошибки — не только исправление PHP. Команда получает повреждённые данные, повторную загрузку и миграцию, которую уже нельзя безопасно повторить.
\nТезис: совместимость Bitrix проверяют не по имени класса и не по номеру версии. Нужна граница из трёх фактов: модуль подключён, нужная поверхность API доступна, а вход и выход совпадают с контрактом проекта. Только после этого выбирают legacy-вызов, D7 или адаптер между ними.
\nУ старого и нового API может быть одна предметная область, но разные правила. Документация Bitrix указывает CUser и Bitrix\\Main\\UserTable как поверхности работы с пользователями. Это не утверждение, что вызовы взаимозаменяемы в конкретном проекте. У них могут различаться способ выборки, типы полей, ошибки, события и требования к версии ядра.
Сначала проверяют загрузчик. CModule::IncludeModule('iblock') или \\Bitrix\\Main\\Loader::includeModule('iblock') отвечает на вопрос «модуль установлен и подключён?». Ответ true ещё не подтверждает нужный метод и mapping полей. Ответ false закрывает путь к следующему слою: нельзя маскировать отсутствие модуля вызовом класса, который случайно доступен через другой bootstrap.
Затем проверяют поверхность. Нужны точные операции: найти запись, создать, обновить, получить идентификатор и разобрать ошибку. Проверка только существования класса слишком слаба. Она не отвечает, принимает ли метод нужные поля и сохранит ли различие между отсутствующим полем и явной очисткой.
\nПоследний слой — смысл результата. Успешный ID доказывает, что операция вернула идентификатор. Он не доказывает, что значение записалось в нужный формат, обработчики отработали ожидаемо, а публичная выборка увидит запись. Поэтому контракт нужно проверять через повторное чтение и отрицательные случаи.
\nНиже — учебная функция. Она не обращается к реальному серверу и не обещает поддержку перечисленных версий. Входной manifest нужно получить в конкретной среде безопасной диагностикой. Функция только разделяет отсутствие модуля, legacy-поверхность и D7-поверхность.
function chooseUserSurface(manifest) {\n if (!manifest.moduleLoaded) {\n return { kind: 'stop', reason: 'module-not-loaded' };\n }\n\n if (manifest.methods.includes('CUser::Update')) {\n return { kind: 'legacy', operation: 'update-user' };\n }\n\n if (manifest.methods.includes('Bitrix\\\\Main\\\\UserTable')) {\n return { kind: 'd7', operation: 'update-user' };\n }\n\n return { kind: 'stop', reason: 'operation-not-confirmed' };\n}\n\n// Учебные данные. Это не результат работы production-установки.\nconst surface = chooseUserSurface({\n moduleLoaded: true,\n methods: ['CUser::Update'],\n});\n\nconsole.log(surface);\n// { kind: 'legacy', operation: 'update-user' }\nВажен порядок условий. Сначала функция останавливается при отсутствии модуля. Затем она выбирает подтверждённую операцию, а не любой похожий класс. Если обе поверхности доступны, выбор должен задавать адаптер проекта: например, установленная версия, зафиксированный набор полей и проверенная матрица регрессии. Автоматически предпочитать D7 только потому, что он новее, нельзя.
\nАдаптер должен принимать канонический вход. Для пользователя это может быть объект с id, email, phone и явными состояниями missing и clear. Внутри адаптера поля переводятся в формат выбранного API. Так legacy-детали не расползаются по формам, импорту и обработчикам.
function toLegacyFields(user) {\n const fields = {};\n\n if (user.email.state === 'value') {\n fields.EMAIL = user.email.value;\n } else if (user.email.state === 'clear') {\n fields.EMAIL = '';\n }\n\n if (user.phone.state === 'value') {\n fields.PERSONAL_PHONE = user.phone.value.trim();\n }\n\n return fields;\n}\nЭтот код показывает только mapping. Он не вызывает CUser::Update, не проверяет права и не описывает локальные события. В рабочем проекте перед вызовом нужно зафиксировать, что означает пустое поле, кто владеет нормализацией, какие ошибки возвращает API и что должен увидеть read-back. Если D7-модель хранит поле в другом представлении, адаптер должен преобразовать его обратно в тот же канонический результат.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Класс найден, вызов падает на части серверов | Модуль не установлен или не подключён в этом bootstrap | Проверить IncludeModule в том же окружении и записать результат | Остановить операцию с диагностикой либо подключить модуль явно |
| Одинаковое имя поля даёт разный результат | Различаются тип, формат или пользовательское поле | Сравнить mapping, тип значения, missing и clear | Оставить преобразование в адаптере и добавить read-back |
| Update вернул успех, но данные не видны | Проверяется только код ответа; фильтр или событие меняет выборку | Прочитать запись по ID и выполнить контрольный запрос с условиями каталога | Разделить факт записи и публичную видимость |
| После обновления появился неизвестный метод | Документация описывает другую версию или другую поверхность | Сверить версию ядра, модуль и фактический manifest операций | Вернуть адаптер к подтверждённой операции или ограничить поддержку |
| Повторный импорт создаёт новые записи | В контракте нет стабильного ключа и идемпотентного поиска | Повторить тот же вход и сравнить внешний ключ и результат чтения | Найти существующую запись по согласованному ключу до создания |
| Миграция проходит на тесте, но меняет production-смысл | Тест проверяет ID, но не события, права и пустые состояния | Добавить characterization-тесты для успеха, ошибки, retry и очистки | Не заменять поверхность до закрытия отрицательного пути |
Документация Bitrix описывает публичную поверхность, но не знает локальные обработчики, права, переопределения в /local, структуру инфоблока и фактический bootstrap. Две установки с одним номером версии могут иметь разные модули и данные. Поэтому ссылка на страницу API не является доказательством совместимости проекта.
Manifest тоже не равен полному тесту. Он подтверждает доступность слоя, но не доказывает корректность SQL, событий и бизнес-правил. Не следует печатать в диагностике пользовательские данные. Достаточно версии, имени модуля и названий операций. Секреты и значения полей в такой вывод не входят.
\nИногда правильное решение — не мигрировать. Если legacy-вызов покрыт тестами, выполняет нужную операцию и новый API не даёт проверяемого выигрыша, адаптер может сохранить старую поверхность. Если новый метод доступен, но его mapping или события не доказаны, переход откладывают. Остановка с причиной безопаснее частичной миграции и ручного восстановления данных.
\nГраница готова, когда повторяемая проверка показывает: нужный модуль подключается; операция существует в каждой обязательной среде; поля имеют записанный mapping; значение, отсутствие и очистка дают ожидаемый read-back; ошибка и отказ прав не превращаются в успех; повторный запуск не создаёт дубль; а сборка и тесты проходят без ручного вмешательства. Для каждой версии нужен сохранённый результат проверки. Если нет хотя бы одного из этих доказательств, готов только план проверки, а не миграция.
\nОшибка при миграции Bitrix часто появляется не в строке вызова. Документация нашла класс, автозагрузка сработала, операция вернула успешный результат — а на другой установке модуль не подключён, пользовательское поле имеет другой тип или обработчик изменяет данные после записи. Через несколько часов форма теряет значение, импорт создаёт дубль, а откат уже требует ручного восстановления.
\nГлавный вывод: совместимость нельзя вывести из имени класса или номера версии. Её нужно доказать для конкретной операции: модуль подключён в нужном bootstrap, метод действительно доступен, поля имеют согласованный mapping, а результат выдерживает повторное чтение и отрицательные случаи. Если хотя бы один слой неизвестен, миграция ещё не готова.
\nBitrix документирует два поколения поверхности для одной предметной области. Класс CUser относится к старому ядру, а Bitrix\\Main\\UserTable — к D7 и ORM. В документации прямо указано, что UserTable является аналогом CUser. Это полезная подсказка для поиска, но не обещание побитной совместимости: разные методы принимают разные аргументы, возвращают разные типы результата и по-разному сообщают об ошибках.
Начинать нужно с модуля. Для пользовательской области это обычно модуль main, а не iblock. Старый вызов CModule::IncludeModule('main') проверяет, установлен ли модуль, и подключает его файл include.php. D7-вариант \\Bitrix\\Main\\Loader::includeModule('main') подключает модуль по имени и возвращает true или false; в документации для метода также перечислено исключение LoaderException. Ни один из этих ответов не доказывает, что нужная операция и её поля совпадают с ожиданиями приложения.
Следующий слой — поверхность операции. Наличие класса доказывает только возможность разрешить имя. Для миграции обновления пользователя нужно отдельно подтвердить CUser::Update или выбранный D7-вызов, а затем зафиксировать набор полей, права и наблюдаемый результат. Проверка через class_exists без проверки операции создаёт ложное чувство совместимости.
Сравнивать нужно не названия классов, а один сценарий от входа до чтения. Например, пусть импорт обновляет email и телефон пользователя по стабильному локальному ID. В legacy-поверхности поля называются EMAIL и PERSONAL_PHONE. Документация CUser описывает их как строковые поля. Но проект может добавлять пользовательские поля, нормализовать телефон, ограничивать смену email или подключать обработчики события. Эти правила находятся за пределами общей сигнатуры.
| Слой | CUser | D7 UserTable | Доказательство в проекте |
|---|---|---|---|
| Подключение | CModule::IncludeModule('main') | Loader::includeModule('main') | Успешный результат в том же bootstrap, где выполняется операция |
| Операция | CUser::Update($id, $fields) | ORM-метод выбранной модели | Точный вызов, аргументы, тип результата и обработка ошибки |
| Поля | EMAIL, PERSONAL_PHONE, UF_* | Поля из карты сущности и их типы | Таблица соответствий для value, missing и clear |
| Ошибка | false и LAST_ERROR | Результат ORM и исключения | Тест отказа прав, неверного поля и недоступной записи |
| Результат | Булево подтверждение операции | Результат ORM-операции | Повторное чтение и контроль публичной выборки |
У этой таблицы есть важная оговорка: последний столбец не заполняется документацией автоматически. Его заполняет команда на своей установке. В частности, официальная страница CUser::Update сообщает, что метод возвращает true при успехе и false при ошибке, а текст ошибки находится в LAST_ERROR. Та же страница отдельно говорит: если пользователя с указанным ID нет, ошибки не возникает. Значит, одного булева результата недостаточно — отсутствие записи нужно проверять до или после изменения.
Диагностика должна быть безопасной: она выводит имена модулей и операций, но не email, телефоны, токены и значения пользовательских полей. Запускайте её в том же окружении и через тот же bootstrap, который использует рабочий код. Иначе результат описывает диагностический скрипт, а не реальный путь запроса.
\n<?php\nuse Bitrix\\Main\\Loader;\n\nfunction inspectUserSurface(): array\n{\n $report = [\n 'module' => 'main',\n 'moduleLoaded' => false,\n 'legacyUpdate' => false,\n 'd7UserTable' => false,\n ];\n\n try {\n $report['moduleLoaded'] = Loader::includeModule('main');\n } catch (\\Throwable $exception) {\n return $report + ['reason' => 'module-load-exception'];\n }\n\n if (!$report['moduleLoaded']) {\n return $report + ['reason' => 'module-not-loaded'];\n }\n\n $report['legacyUpdate'] = class_exists('CUser')\n && method_exists('CUser', 'Update');\n $report['d7UserTable'] = class_exists('\\Bitrix\\Main\\UserTable')\n && method_exists('\\Bitrix\\Main\\UserTable', 'getMap');\n\n return $report;\n}\nЭтот фрагмент отвечает только на вопрос о доступности слоёв. Он не выбирает D7 автоматически и не пишет данные. Если moduleLoaded равен false, возвращается причина остановки. Если модуль загружен, но обе поверхности имеют значение false, нужно проверять bootstrap и версию ядра, а не подменять имя класса.
Версия тоже входит в отчёт, но не заменяет поведенческую проверку. В официальной документации CUser и UserTable есть собственные границы версий: CUser описан с версии 3.0.6, UserTable наследует DataManager, а для старых версий модуля Main документация указывает другой класс-родитель. Эти сведения помогают понять, какой код вообще может встретиться в установке. Они не отвечают, как локальные обработчики и пользовательские поля поведут себя в конкретном проекте.
\nАдаптер должен принимать один формат данных независимо от выбранной поверхности. У каждого поля полезно различать три состояния: missing — поле не участвует в обновлении, value — записывается новое значение, clear — значение очищается явно. Если передавать пустую строку вместо отдельного состояния, код теряет намерение вызывающей стороны и начинает зависеть от поведения конкретного API.
function toLegacyFields(array $user): array\n{\n $fields = [];\n\n if ($user['email']['state'] === 'value') {\n $fields['EMAIL'] = trim($user['email']['value']);\n } elseif ($user['email']['state'] === 'clear') {\n $fields['EMAIL'] = '';\n }\n\n if ($user['phone']['state'] === 'value') {\n $fields['PERSONAL_PHONE'] = trim($user['phone']['value']);\n } elseif ($user['phone']['state'] === 'clear') {\n $fields['PERSONAL_PHONE'] = '';\n }\n\n return $fields;\n}\nПреобразование ещё не является обновлением. Перед записью адаптер проверяет, что вход содержит допустимый идентификатор, а после записи перечитывает запись по тому же ключу. Для CUser это может быть GetByID или контролируемый запрос, для D7 — выбранная ORM-операция чтения. Сравнивать нужно канонический результат: нормализованный телефон, фактический email, отсутствие поля и состояние, которое видит дальнейшая бизнес-логика.
Успех записи и видимость — разные утверждения. Обработчик может изменить поле, индекс или статус; кеш может показать старое значение; фильтр каталога может исключить запись. Поэтому characterization-тест должен проверять как минимум исходное чтение, запись, повторное чтение и запрос потребителя. Если запись должна быть идемпотентной, второй запуск с тем же внешним ключом обязан обновить найденную сущность, а не создать новую.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Класс найден, а вызов падает на части серверов | main не установлен или не подключён в реальном bootstrap | Записать результат Loader::includeModule('main') без пользовательских данных | Остановить операцию с причиной либо исправить подключение |
| Метод существует, но поле отклоняется | Тип, имя или форма пользовательского поля различаются | Сверить карту полей, входной тип и состояние clear | Оставить преобразование в адаптере и добавить отрицательный тест |
| Update вернул успех, но пользователя нет в чтении | ID не существует, чтение использует другой фильтр или сработал обработчик | Проверить существование ID, перечитать запись и выполнить запрос потребителя | Разделить отсутствие записи, факт изменения и публичную видимость |
| После перехода значения стали другими | Legacy и ORM по-разному нормализуют дату, телефон или пользовательское поле | Прогнать одинаковый набор входов и сравнить канонический read-back | Зафиксировать mapping либо оставить прежнюю поверхность |
| Повторный импорт создал дубль | Перед созданием не используется стабильный внешний ключ | Повторить вход и сравнить количество записей и ключи | Сначала искать сущность, затем обновлять или создавать |
| На тесте всё работает, в рабочей среде — нет | Различаются права, обработчики, модули, данные или bootstrap | Сравнить обезличенный manifest и прогнать smoke-набор в целевой среде | Ограничить поддержку средами с подтверждённым контрактом |
main в реальном пути выполнения. Отдельно сохранить результат и исключение, не записывая значения полей.missing, value, clear и внешний ключ.Официальная документация описывает публичный API, но не локальную конфигурацию. Она не знает обработчики в /local, права, состав пользовательских полей, кеши, настройки сайтов и фактический bootstrap. Даже одинаковая версия ядра не гарантирует одинаковый набор модулей и данных.
Проверка доступности метода не является тестом миграции. method_exists не выявляет семантику события, права записи, ограничения базы и работу фильтра потребителя. Manifest нужен для ранней остановки и сравнения сред, а не как единственное доказательство.
Нельзя переносить mapping из примера в проект без проверки. Имена EMAIL и PERSONAL_PHONE относятся к стандартной модели пользователя, но у проекта могут быть собственные UF_*-поля, другой источник истины или обязательная нормализация. Секреты и персональные значения в диагностический вывод не входят.
Иногда безопасный результат — не мигрировать. Если legacy-вызов покрыт тестами, а новый API не даёт измеримого выигрыша, адаптер может сохранить старую поверхность. Если модуль, операция или mapping не подтверждены, остановка с понятной причиной дешевле частичной записи и ручного восстановления.
\nПереход можно считать готовым только после повторяемого набора доказательств: модуль подключается в каждой обязательной среде; точная операция доступна; поля имеют записанный mapping; value, missing и clear дают ожидаемый read-back; ошибка, исключение и отказ прав не превращаются в успех; несуществующий ID обрабатывается явно; повторный запуск не создаёт дубль; запрос потребителя видит правильное состояние. Если не закрыт хотя бы один пункт, готова проверка неизвестности, но не замена API.
\ntrue/false и исключение LoaderException.true/false, LAST_ERROR и поведение при несуществующем ID.После замены старого вызова Bitrix страница продолжает открываться, но новый пользователь не создаётся. В логах остаётся общий отказ. Административная форма показывает успех, хотя обработчик, который отправляет данные во внешнюю систему, не сработал. Цена ошибки — не один сломанный метод. Команде приходится восстанавливать порядок событий, формат полей и правила, которые раньше были спрятаны в legacy-коде.
\nВозраст класса не доказывает его опасность. Имя нового API не доказывает совместимость. Безопасное решение начинается с наблюдаемого контракта: какие входы принимает код, какое состояние меняет, что возвращает, какие события запускает и как сообщает об отказе. Пока контракт не проверен, есть три действия: сохранить вызов, обернуть его адаптером или заменить после сравнения поведения.
\nТипичный симптом выглядит безобидно: после обновления вызова исчезает запись, меняется формат телефона или внешний обработчик получает пустой идентификатор. Система может не упасть. Она продолжит работать с неполным состоянием. Поэтому проверять нужно не только отсутствие исключения. Нужны чтение результата, события, права, повторный запуск и реакция на частичный отказ.
\nРиск выше, если одна функция выполняет несколько операций. Она может привести email к нижнему регистру, создать пользователя, вызвать обработчик и вернуть ID. Замена класса меняет сразу четыре соглашения. Сравнение строк вызова этого не показывает.
\nВ Bitrix старый CUser и D7-класс Bitrix\\\\Main\\\\UserTable относятся к одной предметной области, но это не делает их взаимозаменяемыми в проекте. Нужно проверить mapping полей, способ ошибки, порядок событий и доступность модуля в конкретной установке. Документация даёт публичную поверхность API. Она не знает локальные обработчики, пользовательские поля и скрытые callers.
Сохранить — оставить текущий вызов и ограничить изменение вокруг него. Это подходит, когда callers мало, побочные эффекты не описаны, а задача не требует нового контракта. Сохранение не убирает технический долг. Оно не даёт неизвестному поведению разойтись дальше и оставляет обратимый шаг.
\nОбернуть — поставить одну границу между приложением и Bitrix API. Адаптер принимает поля проекта, проверяет обязательные значения, вызывает legacy-код и переводит ошибку в согласованный результат. Внешний код перестаёт зависеть от PERSONAL_PHONE, подключения модуля и объекта, в котором Bitrix хранит последнюю ошибку.
Заменить — перейти на новый контракт, а не только поменять имя класса. Сначала описывают mapping полей, порядок операций, успешный результат, ошибку, события и требования к версии. Затем запускают старый и новый путь на одинаковых входах. Если различие намеренное, его фиксируют как изменение поведения. Если случайное, замену откладывают.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Вызов вернул ID, но внешняя система не получила запись | событие зависит от старого порядка | журнал событий и повторное чтение записи | сохранить путь или обернуть его |
| Обновление прошло, поле стало пустым | пустое значение трактуется как очистка | сравнить отсутствие поля, null и пустую строку | задать mapping и правило пустого значения |
| Класс не найден | модуль не подключён или API недоступно в версии | проверить IncludeModule и поверхность методов | остановить замену и уточнить контракт |
| Повторный запуск создал дубль | операция не различает обработанный объект | запустить один вход дважды и сравнить ID | добавить ключ идемпотентности |
| Новый вызов работает для одного caller | caller-ы передают разные форматы | найти все места вызова и фактические входы | поставить адаптер с единым входом |
Таблица задаёт порядок вопросов, но не доказывает причину сама. Для каждой строки нужен артефакт: лог, diff полей, результат повторного запуска, список callers или проверка подключения модуля. Если артефакта нет, решение остаётся гипотезой.
\nНиже приведён учебный PHP-фрагмент. Он показывает форму узкой границы и не утверждает, что конкретный проект должен обновлять пользователя именно так. Перед применением нужно сверить версию Bitrix, права, обработчики и правила хранения контактов.
\n<?php\nfunction updateUserContact(int $userId, array $input): int\n{\n if ($userId < 1) {\n throw new InvalidArgumentException('userId must be positive');\n }\n\n $fields = [];\n if (array_key_exists('email', $input)) {\n $email = trim((string) $input['email']);\n if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {\n throw new InvalidArgumentException('email is invalid');\n }\n $fields['EMAIL'] = strtolower($email);\n }\n\n if (array_key_exists('phone', $input)) {\n $fields['PERSONAL_PHONE'] = trim((string) $input['phone']);\n }\n if ($fields === []) {\n throw new InvalidArgumentException('no fields to update');\n }\n\n $user = new CUser();\n if ($user->Update($userId, $fields) === false) {\n throw new RuntimeException($user->LAST_ERROR);\n }\n return $userId;\n}\nАдаптер делает три вещи. Он отличает отсутствие поля от переданного значения. Он приводит email к одному формату. Он переводит отказ Bitrix в исключение, которое может обработать caller. Он не объявляет успехом сам факт вызова метода. После обновления нужен read-back: получить пользователя, сравнить поля и проверить побочный обработчик.
\nОтрицательный путь важнее короткого примера. Если проект разрешает очистить email пустой строкой, проверка выше неверна. Если обработчик ожидает исходный регистр, lowercase меняет контракт. Если старый метод обновляет связанные поля, узкий wrapper скрывает обязательную операцию. В этих случаях пример нельзя переносить целиком. Нужно изменить mapping, расширить контракт или оставить legacy-вызов.
\nНачните с callers. Поиск по имени метода недостаточен: вызов может находиться в сервисе, обработчике события, шаблоне или административной форме. Для каждого caller запишите входные поля, права, ожидаемый результат и реакцию на ошибку. Если один caller передаёт пустую строку, а другой не передаёт поле, это разные операции.
\nЗатем зафиксируйте владельца состояния. Кто создаёт запись? Кто меняет её после события? Кто формирует внешний ID? Какой код считается успехом? Где находится последняя ошибка? Ответы должны подтверждаться кодом, логом или чтением данных. Фраза «Bitrix сам вызывает обработчик» не является проверкой: обработчик зависит от модуля, версии, прав и условий события.
\nТретья граница — версия. Для старого API проверьте доступность модуля и метода в выполняемой среде. Для нового API проверьте namespace, mapping полей, типы значений и поведение исключений. Вызов CModule::IncludeModule должен быть частью проверки границы, а не строкой, которую добавляют после сбоя.
<?php\nif (!CModule::IncludeModule('main')) {\n throw new RuntimeException('Bitrix main module is unavailable');\n}\n\n$user = new CUser();\n$ok = $user->Update($userId, $fields);\nif (!$ok) {\n throw new RuntimeException($user->LAST_ERROR);\n}\nЭтот второй фрагмент тоже учебный. Он проверяет подключение модуля, но не проверяет права, события, mapping или корректность данных. В конкретном проекте идентификатор модуля и способ вызова нужно сверить с установленной версией.
\nПереход на D7 или другой новый слой не переносит автоматически пользовательские поля, события, права и внешние идентификаторы. Даже правильный mapping опасен, если операция выполняется внутри транзакции или участвует в импорте. Нужно понять поведение при повторе и частичном отказе.
\nUnit-тест на чистом объекте не проверяет модуль, реальную версию, события и права. Но полная копия production-среды не обязательна для первого шага. Достаточно сузить границу, зафиксировать факты и назвать, чего учебная проверка не покрывает. Не следует объявлять замену готовой только потому, что новый метод вернул ожидаемый ID.
\nИногда правильный результат — не менять код. Если нет воспроизводимого входа, список callers неполный, а ошибка зависит от неизвестного обработчика, сохранение вызова снижает риск. Сначала получают недостающий контракт. Затем возвращаются к адаптеру или замене.
\nИзменение готово, когда другой разработчик может повторить проверку по записи входа и получить тот же результат. В записи есть callers, версия и подключение модуля, mapping полей, события, успешный и отрицательный путь, read-back и способ отката. Для замены старый и новый путь дают одинаковый результат либо команда явно согласовала изменение. Если пункт неизвестен, готова не миграция, а следующая проверка.
\nПосле замены старого вызова Bitrix страница может продолжить отвечать 200, а новый пользователь всё равно не появится во внешней системе. В административной форме будет успех, в логах — общий отказ, а повторная попытка иногда создаст дубль. Такая поломка возникает не из-за возраста класса. Она появляется, когда команда сравнивает две строки кода и не сравнивает поведение вокруг них.
\nРазберём один типовой сценарий: проект обновляет работу с пользователем и выбирает между CUser и D7 Bitrix\\Main\\UserTable. Безопасное решение опирается на наблюдаемый контракт. Нужно знать входные поля, владельца состояния, результат, ошибки, события, права и поведение при повторе. Пока хотя бы один из этих пунктов неизвестен, есть три честных действия: сохранить вызов, поставить адаптер или отложить замену до получения фактов.
Сначала запишите не название класса, а то, что увидел пользователь или соседняя система. Например: форма вернула успешный ответ, запись в таблице пользователей изменилась, но обработчик не отправил внешний идентификатор. Другой вариант: обновление прошло, а поле телефона исчезло. Третий: второй запуск с теми же данными создал ещё одну запись.
\nДля каждого симптома нужны пять наблюдений: точный вход, caller, результат основного вызова, изменённое состояние и побочный эффект. Caller — это конкретный код, который передаёт данные в операцию: контроллер, обработчик события, импорт или административная форма. Один и тот же метод могут вызывать несколько callers с разными правилами пустых значений и правами.
\nСформулируйте границу так: «получить email и телефон, изменить пользователя с ID 42, вернуть подтверждённый результат, запустить нужный обработчик». Если в этой фразе нет способа подтвердить результат, контракт неполон. Возвращённый ID или true ещё не доказывают, что дочерняя интеграция приняла данные.
У старого вызова обычно несколько обязанностей, даже если они скрыты в одной функции. Он может нормализовать email, очищать поле пустой строкой, проверять права, вызывать обработчики и записывать внешний ID. Миграция, которая переносит только список полей, переносит не контракт, а его видимую часть.
\nВход. Зафиксируйте отличие между отсутствующим ключом, null, пустой строкой и пробелами. Для проекта это могут быть четыре разных команды: не менять значение, очистить его, отклонить запрос или сохранить нормализованный текст.
Результат. У CUser::Update официальный контракт — логическое значение: при ошибке метод возвращает false, а текст находится в LAST_ERROR. Если проект считает успехом отправку данных в CRM, это уже второй результат, который надо проверять отдельно.
События. Обработчик может менять состояние после основной записи. В официальном описании OnAfterUserUpdate прямо сказано, что событие вызывается после попытки изменения свойств методом CUser::Update, а в параметрах доступны результат и сообщение ошибки. Поэтому при переходе на ORM нельзя автоматически считать, что локальный обработчик, его порядок и его данные останутся прежними. Их нужно найти и проверить в конкретной версии и конфигурации.
Права и версия. Доступность класса, модуля, поля и операции зависит от выполняемой установки. Документация описывает публичный API, но не знает пользовательские поля проекта, зарегистрированные обработчики и ограничения роли. Это не недостаток документации, а граница того, что можно утверждать по ссылке.
\nBitrix называет Bitrix\\Main\\UserTable аналогом старого CUser, но слово «аналог» не означает замену один к одному. Страница D7 описывает UserTable как класс для работы с пользователями, наследующий DataManager. У DataManager::update другая форма контракта: статический вызов принимает первичный ключ и массив данных и возвращает объект результата.
У старого и нового путей различаются как минимум поверхности ошибок и событий. Старый путь — объект CUser, булев результат и LAST_ERROR. ORM-путь — статический метод сущности и объект результата, который надо обработать по правилам D7. Если адаптер скрывает эту разницу, он обязан определить единый результат наружу: например, подтверждённое изменение пользователя или структурированную ошибку.
Данные тоже нельзя переносить механически. Поле PERSONAL_PHONE может быть стандартным, а UF_... — пользовательским полем со своим типом и форматом. Массив для поля-списка, строка для текста и значение для файла требуют разных проверок. Сверьте карту полей в работающей установке и зафиксируйте преобразования рядом с контрактом адаптера.
Ещё одна граница — операция, а не только запись. Если после изменения пользователя срабатывает синхронизация, уведомление или расчёт доступа, сравнивайте цепочку целиком. Новый ORM-вызов, который обновил строку, может быть технически успешным и функционально неполным.
\n| Наблюдение | Что проверить | Артефакт | Безопасный следующий шаг |
|---|---|---|---|
| Старый вызов меняет пользователя, но внешний обработчик не даёт запись | какой обработчик запускается и где формируется внешний ID | список обработчиков, лог и read-back | сохранить путь до фиксации цепочки или обернуть её |
| Пустой email или телефон меняет состояние | отличие отсутствующего ключа, null и пустой строки | набор входов и снимок полей до/после | описать mapping и явное правило очистки |
| Один путь возвращает false, другой — объект результата | как caller распознаёт ошибку и получает сообщение | контракт ответа и отрицательный тест | привести ошибки к одному интерфейсу адаптера |
| Класс или поле недоступны в окружении | версию ядра, модуль, карту полей и права роли | версия, проверка загрузки и результат чтения карты | остановить замену до устранения неизвестного |
| Повторный запуск создаёт дубль | идемпотентность и владелец внешнего идентификатора | два одинаковых запуска и сравнение ID | добавить ключ повторения или оставить старый путь |
Таблица не является доказательством причины. Она задаёт минимальный набор наблюдений. Для строки «обработчик не дал запись» нужен реальный журнал или чтение внешней системы; для строки о пустом значении — входной набор и состояние до и после. Если команда может назвать только предположение, миграция ещё не готова.
\nНиже — небольшой PHP-пример границы вокруг CUser::Update. Он намеренно принимает проектные имена email и phone, а наружу отдаёт ID только после успешного вызова. В этом варианте пустой телефон означает очистку, а пустой email отклоняется. Это решение примера, не универсальное правило Bitrix.
<?php\nfunction updateUserContact(int $userId, array $input): int\n{\n if ($userId < 1) {\n throw new InvalidArgumentException('userId must be positive');\n }\n\n $fields = [];\n if (array_key_exists('email', $input)) {\n $email = trim((string) $input['email']);\n if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {\n throw new InvalidArgumentException('email is invalid');\n }\n $fields['EMAIL'] = strtolower($email);\n }\n\n if (array_key_exists('phone', $input)) {\n $fields['PERSONAL_PHONE'] = trim((string) $input['phone']);\n }\n if ($fields === []) {\n throw new InvalidArgumentException('no fields to update');\n }\n\n $user = new CUser();\n if ($user->Update($userId, $fields) === false) {\n throw new RuntimeException($user->LAST_ERROR);\n }\n return $userId;\n}\nВ примере есть три существенные границы. array_key_exists не даёт случайно превратить отсутствие поля в очистку. Нормализация email вынесена в одно место. Ошибка старого API переводится в исключение, понятное caller-у. При этом функция не обещает, что внешний обработчик завершился: после неё нужен read-back пользователя и отдельная проверка побочного результата.
У кода есть намеренные ограничения. Он не проверяет права, не знает обязательность email в конкретной установке и не нормализует телефон по правилам бизнеса. Он также не гарантирует, что ID существует: официальное описание CUser::Update отмечает, что отсутствие пользователя с указанным ID само по себе не даёт ошибки. Если для проекта это важно, адаптер должен явно прочитать запись до обновления или проверить результат чтением после него.
Не начинайте с массовой замены. Сначала соберите characterization test — тест, который фиксирует фактическое поведение старого пути, даже если оно кажется неудобным. Для одинакового входа запишите поля до операции, ответ, ошибку, события, внешний ID и поля после операции. Такой тест не объявляет старое поведение правильным; он показывает, что именно нельзя потерять случайно.
\nЗатем подготовьте отдельный тест для D7. Официальный DataManager::update возвращает объект результата, поэтому проверьте не только отсутствие исключения, но и успешность результата, сообщение ошибки и фактическое состояние записи. Если объект результата обрабатывается иначе, чем LAST_ERROR, адаптер должен скрыть эту разницу, а не заставлять каждый caller знать обе модели.
События проверяйте наблюдением, а не названием. Найдите регистрации OnBeforeUserUpdate, OnAfterUserUpdate и проектные обработчики, проверьте условия их выполнения и порядок записи. Для нового пути отдельно установите, какие ORM-события и локальные интеграции используются. Если официальная страница описывает только старое событие, это повод провести проверку, а не обещать совместимость.
Минимальный набор сравнений выглядит так: корректный email и телефон; отсутствие email при изменении телефона; пустой телефон для очистки; невалидный email; несуществующий ID; недостаточные права; повторный запуск; отказ внешней системы после записи. Для каждого случая нужны ожидаемый результат, наблюдаемое состояние и решение о том, допустимо ли отличие.
\nОставить — разумно, если задача не требует нового API, callers немного, а побочные эффекты ещё не описаны. Это не отказ от улучшений. Команда фиксирует границу и избегает изменения нескольких неизвестных одновременно.
\nОбернуть — полезно, когда callers несколько или старый API проникает в разные слои. Адаптер принимает канонический вход проекта, применяет mapping, единообразно возвращает успех или ошибку и оставляет место для read-back. Его задача — уменьшить число мест, где живут знания о Bitrix, а не спрятать незавершённую миграцию.
\nЗаменить — можно после сравнения поведения и осознанного решения об отличиях. Для каждого намеренного отличия нужна запись: что меняется, почему это безопасно, кто владеет обновлённым состоянием и как откатить релиз. Если команда не может воспроизвести старый результат, замену лучше отложить.
\nЭта схема подходит для выбора границы вокруг legacy-вызова. Она не заменяет аудит безопасности, ревизию ролей и прав, миграцию схемы или проверку производительности. Если операция работает в импорте, транзакции или очереди, отдельно исследуйте повтор, частичный отказ и порядок подтверждения внешней системы.
\nУчебный пример нельзя копировать без сверки версии, обязательных полей, формата пользовательских полей, прав и зарегистрированных обработчиков. Нормализация email и разрешение очистки телефона — проектные решения. Для файла, списка или множественного пользовательского поля понадобятся другие типы входа и отдельные тесты.
\nUnit-тест на чистом объекте не доказывает поведение реального модуля и событий. Полная копия production для первого шага тоже не обязательна: достаточно ограничить тестовую среду, указать, что она не покрывает, и не выдавать её результат за доказательство совместимости. Критерий готовности простой: другой разработчик повторяет вход, видит тот же результат, понимает намеренные отличия и знает, как вернуть старый путь.
\nLAST_ERROR и параметров обновления.CUser::Update, его результата и сообщения ошибки.Клиент получает 502. В access log шлюза видны маршрут, время и внешний статус. В журнале приложения нет записи с тем же запросом. Инженер открывает последний релиз и начинает искать ошибку в коде. Через несколько часов выясняется, что шлюз не дождался upstream или не смог разобрать его ответ. Исправление приложения не меняет ситуацию, а время расследования уже потеряно.
\nЦена ошибки состоит не только в часах. Команда может откатить исправный релиз, увеличить таймаут без понимания причины или добавить повторные запросы к уже перегруженной зависимости. Следующий дежурный получит уверенную, но неверную запись: «упало приложение». Она направит новое расследование по тому же ложному следу.
\nРабочий тезис простой: 502 надо разбирать как разрыв цепочки событий. Сначала фиксируют, где появился статус. Затем ищут связанную запись приложения по идентификатору. Только после подтверждения передачи запроса переходят к зависимости. Отсутствие записи — тоже результат. Он ограничивает вывод и открывает отдельную проверку.
\nRFC 9110 определяет 502 как ответ gateway или proxy, который получил недействительный ответ от сервера, к которому обращался для выполнения запроса. Это описание роли узла, а не доказательство того, что конкретный сервис сломан. Клиент видит ответ ближайшей границы. Причина может находиться между шлюзом и upstream: в соединении, таймауте, формате ответа, маршрутизации или фильтре.
\nПоэтому первая запись должна называть субъект результата. Поле edge.status=502 точнее, чем сообщение «сервер вернул 502». Рядом нужны route, method, duration_ms, время события, имя узла и ключ корреляции. Без этих полей access log подтверждает симптом, но не даёт короткого пути к следующему слою.
Для одной попытки запроса нужны минимум два источника: access log на границе и application log в сервисе. Они связываются по точному request_id или по trace context. Время и путь помогают проверить совпадение, но не должны быть единственным ключом. Два запроса к одному маршруту могут попасть в одно и то же временное окно.
Если application event найден, сравните время, маршрут, статус и длительность. Запись приложения с 500 показывает, что запрос дошёл до приложения и там завершился ошибкой. Она не объясняет, почему произошёл отказ зависимости. Запись приложения с 200 при внешнем 502 показывает расхождение границ: надо проверять retry, кэш, преобразование статуса или другой upstream.
\nЕсли application event не найден, не подставляйте приложение в роль виновника. Проверьте timeout до приложения, правила маршрутизации, формат идентификатора, фильтры коллектора и задержку доставки. После этого можно сказать только: «внешний отказ подтверждён, запись приложения не найдена». Это полезный вывод, потому что он отделяет неисправность канала наблюдения от отказа бизнес-операции.
\n| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| 502 в edge, application event отсутствует | timeout, маршрут до сервиса или потеря записи | сверить upstream, окно времени, collector и формат id | зафиксировать разрыв; не обвинять приложение |
| 502 в edge, application 500 с тем же id | ошибка обработки запроса в сервисе | сравнить время, route, статус и dependency event | исследовать ошибку приложения и её границу |
| 502 в edge, application 200 | retry, cache или преобразование ответа на proxy | проверить попытки, upstream и mapping статусов | разделить результат приложения и результат клиента |
| В access нет request id | неполная схема structured log | проверить конфигурацию полей и передачу заголовка | исправить корреляцию до следующего разбора |
Ниже приведён учебный пример для локальной проверки идеи. Массивы не взяты из production и не доказывают поведение конкретного шлюза. Функция получает события одной попытки и классифицирует только наблюдаемый разрыв.
\nconst edge = [\n { requestId: 'r-1', at: '12:00:01.100', status: 502, durationMs: 3000 },\n { requestId: 'r-2', at: '12:00:02.100', status: 502, durationMs: 420 },\n];\n\nconst app = [\n { requestId: 'r-2', at: '12:00:02.080', status: 500, error: 'db unavailable' },\n];\n\nfunction classify(edgeEvent, appEvents) {\n const appEvent = appEvents.find((event) => event.requestId === edgeEvent.requestId);\n if (!appEvent) return 'gateway-failed-before-app-or-event-missing';\n if (appEvent.status >= 500) return 'app-error-reached-gateway';\n return 'status-mapping-needs-check';\n}\n\nedge.map((event) => ({\n requestId: event.requestId,\n result: classify(event, app),\n}));\n// r-1: gateway-failed-before-app-or-event-missing\n// r-2: app-error-reached-gateway\nДля r-1 код видит только отсутствие события. Это не различает timeout, сбой коллектора и неверный идентификатор. Для r-2 найдено согласованное событие приложения. Оно подтверждает путь запроса, но не доказывает, что база была первопричиной. Следующий запрос должен проверить dependency event, лимит соединений и время ожидания.
В рабочем коде нормализуйте схему на входе, сохраняйте номер попытки и не смешивайте повторные запросы в одну карточку. Не включайте в общий журнал токены, тело формы, email и сырые заголовки. Безопасный fingerprint может помочь связать закрытый источник с публичной карточкой, если правила хранения это разрешают.
\nЛог может быть неполным. Sampling удаляет часть событий. Буферизация меняет порядок доставки. Collector может отбросить запись или обрезать длинное сообщение. Часы сервисов могут расходиться. Поэтому близкое время не заменяет ключ корреляции, а найденный ключ не гарантирует полноту цепочки.
\nОдин request id может пережить retry или быть создан заново на новой попытке. Это надо выяснять по attempt, span id и временным интервалам. Trace context помогает передавать связь между HTTP-границами, но не доказывает, что каждый сервис записал событие или что наблюдаемый участок был причиной сбоя.
Структурированные поля упрощают поиск, но не делают журнал достоверным автоматически. Формат RFC 5424 предусматривает отдельную область для parseable structured data; конкретная система всё равно может неправильно настроить поля, транспорт или collector. Если корреляция часто ломается, сначала исправьте контракт логирования. Новый экран наблюдаемости не компенсирует отсутствующий идентификатор.
\nМетод также не заменяет нагрузочный анализ и проверку контракта ответа. Если 502 появляется только при перегрузке, одного разбора карточки недостаточно. Нужны распределение длительностей, число retry, состояние очередей и лимиты соединений. Эти данные расширяют расследование, но не отменяют первый шаг: определить, где появился внешний статус.
\nРазбор готов, когда для одной повторной попытки можно показать access event, application event или явно подтверждённый разрыв, согласованные время и маршрут, номер попытки и следующий проверяемый вывод. Исправление готово, когда после него тот же сценарий даёт ожидаемый статус, цепочка событий собирается по идентификатору, а отрицательный путь остаётся различимым. Формулировка «проблема решена» без этих наблюдений недостаточна.
\nКлиент получает 502, а в журнале приложения не находится запись с тем же запросом. Самый дорогой ответ в этой ситуации — открыть последний релиз и начать исправлять код. 502 мог сформировать proxy после таймаута, ошибки соединения или недействительного ответа upstream. Могла потеряться и сама запись: sampling, collector или неверное поле корреляции оставляют ту же картину.
\nЦена неверной атрибуции измеряется не только часами. Команда откатывает исправный релиз, повышает таймаут без проверки границы или добавляет повторные запросы к уже перегруженной зависимости. Следующий дежурный получает уверенную формулировку «упало приложение» и повторяет тот же маршрут расследования.
\nНадёжный разбор начинается с вопроса «кто сформировал этот статус?». Сначала фиксируем событие на границе, затем связываем его с попыткой в приложении по идентификатору. Только найденное и согласованное событие разрешает перейти к зависимости. Если запись не найдена, это результат наблюдения, а не доказательство того, что запрос не дошёл.
\nRFC 9110 определяет 502 как статус, который сервер в роли gateway или proxy возвращает после недействительного ответа от входного сервера, к которому он обращался для выполнения запроса. В этой формулировке нет имени конкретного виноватого сервиса. Статус сообщает о проблеме на участке между посредником и upstream либо о том, что посредник не смог принять его ответ.
\nОтделяйте 502 от 504. 502 говорит о недействительном ответе, а 504 — об отсутствии своевременного ответа от upstream или другой вышестоящей системы. На практике конкретный proxy может использовать собственные детали диагностики и маппинг ошибок, поэтому RFC объясняет семантику статуса, но не заменяет документацию вашего узла.
\nВ access-событии зафиксируйте субъект результата: edge.status=502, edge.name и upstream.name, если последний известен. Добавьте route, method, точное время, duration_ms, request_id, trace_id и attempt. Набор полей не универсален, но без него внешний лог показывает симптом и почти не помогает выбрать следующий запрос.
Для одной попытки нужны как минимум access event на границе и application event в сервисе. Ищите по точному request_id или по корректному trace context. Маршрут и временное окно — вторичные признаки: два одинаковых запроса могут прийти одновременно, а часы сервисов могут иметь небольшой сдвиг.
W3C Trace Context задаёт формат заголовка traceparent для передачи идентификаторов между HTTP-границами. Это полезный транспорт связи, но не обещание полной записи: sampled-флаг не гарантирует, что трасса будет сохранена, а промежуточный узел может создать новый контекст при невалидном входе. Поэтому проверяйте и сам заголовок, и фактическое событие в каждом важном слое.
Корреляция считается подтверждённой, если совпали не только ID, но и операция: маршрут, время, номер попытки и ожидаемый слой. Одного одинакового ID мало. При retry ищите дочерние span или отдельные значения attempt; иначе можно принять ответ первой попытки за результат второй.
| Что найдено | Что это подтверждает | Что ещё не доказано | Следующий запрос |
|---|---|---|---|
| Edge 502; application event отсутствует | На границе зафиксирован 502 | Неизвестно, дошёл ли запрос до приложения | Проверить timeout, маршрут, collector и формат ID |
| Edge 502; application 500 с тем же ID и attempt | Приложение обработало эту попытку с ошибкой | Неизвестна первопричина внутри приложения или зависимости | Сверить dependency event, длительность и лимиты |
| Edge 502; application 200 с тем же ID | Приложение завершило свою операцию успешно | Не объяснено расхождение с клиентским статусом | Проверить retry, cache и mapping ответа на proxy |
| В edge нет request ID | Схема наблюдения неполна | Нельзя надёжно связать слои по времени | Исправить генерацию и передачу ID до следующего разбора |
У таблицы есть важная асимметрия. Строка «application event отсутствует» допускает несколько причин: запрос мог остановиться до приложения, запись могла не попасть в хранилище, а идентификатор мог измениться по дороге. Поэтому формулировка должна оставаться отрицательной: «событие не найдено в проверенном источнике и окне», а не «приложение не получило запрос».
\nНиже — самостоятельный пример на JavaScript. События вымышлены и нужны только для проверки корреляции. Функция не пытается угадать первопричину: она различает наличие согласованного application event и оставляет отдельный статус для отсутствующей записи.
\nconst edgeEvents = [\n { requestId: 'r-1', attempt: 1, status: 502, durationMs: 3000 },\n { requestId: 'r-2', attempt: 1, status: 502, durationMs: 420 },\n];\n\nconst applicationEvents = [\n { requestId: 'r-2', attempt: 1, status: 500, durationMs: 180, error: 'dependency unavailable' },\n];\n\nfunction classify(edgeEvent, appEvents) {\n const match = appEvents.find((event) =>\n event.requestId === edgeEvent.requestId\n && event.attempt === edgeEvent.attempt\n );\n\n if (!match) return 'application-event-not-found';\n if (match.status >= 500) return 'application-error-observed';\n return 'application-success-edge-failure-needs-check';\n}\n\nconsole.log(edgeEvents.map((event) => ({\n requestId: event.requestId,\n result: classify(event, applicationEvents),\n})));\n// r-1: application-event-not-found\n// r-2: application-error-observed\nДля r-1 результат ограничен: в переданном массиве нет совпадения по двум полям. Он не различает timeout, потерю записи и ошибку маршрутизации. Для r-2 наблюдается application 500 с теми же ID и попыткой. Это подтверждает обработку запроса приложением, но не доказывает, что строка dependency unavailable — первопричина; её надо сопоставить с журналом зависимости.
В рабочей системе добавьте проверку схемы до классификации: ID не должен быть пустым, attempt — неотрицательным целым, а время — разбираться однозначно. Храните число найденных событий и источник поиска. Не помещайте в общий лог токены, тело формы, email или сырые заголовки; для закрытой корреляции используйте разрешённый идентификатор и действующие правила хранения.
Метод требует хотя бы одного надёжного события на границе. Если access log сам неполон, расследование начинается с восстановления его схемы, а не с чтения application stack trace. Sampling, буферизация и задержка доставки могут удалить или переставить события. Близкое время не заменяет ID, а найденный ID не гарантирует полноту цепочки.
\nRetry меняет картину. Прокси может повторить запрос, приложение — создать новый span, а пользователь — отправить его ещё раз. Один request ID иногда живёт дольше одной попытки, иногда меняется на границе. Нужны attempt, span ID и правила, по которым именно ваша система связывает повторы.
Структурированный лог повышает разбираемость, но не делает данные истинными автоматически. RFC 5424 описывает structured data как parseable-формат и допускает, что collector проигнорирует некорректный элемент. Это означает практическую границу: схему полей надо тестировать на реальном транспорте, а не только на примере конфигурации.
\nРазбор одной карточки не заменяет анализ нагрузки. Если 502 появляется только при насыщении пула, нужны распределение задержек, число retry, состояние очередей и лимиты соединений. Если проблема связана с TLS, DNS, HTTP/2 или конкретным форматом ответа, потребуется проверка соответствующего протокола. Приведённый алгоритм выбирает границу следующего теста, но не обещает одну причину для всех 502.
\nРасследование можно закрывать, когда для повторённой попытки показаны access event и application event либо явно зафиксирован разрыв наблюдения; совпадают маршрут, время и attempt; зависимость проверена там, где это разрешает цепочка; назван следующий измеримый результат. Исправление подтверждено только после повторного запроса: ожидаемый статус получен, цепочка снова связывается по ID, а отрицательный путь остаётся различимым.
\n