Mushegh Manukyan — Founder & CEO of ARMCP

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

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

1. Начать с семантики, а не с переименования полей

Предположим, старая схема хранит цену как целое число:

{
  "schema": 1,
  "symbol": "NOVA_UNIT",
  "price_int": 421735,
  "decimals": 4,
  "venue_time_ms": 1893456000123
}

Новая схема хочет отделить идентичность инструмента, значение и происхождение времени:

{
  "schema": 2,
  "instrument": { "base": "NOVA", "quote": "UNIT" },
  "price": { "mantissa": "421735", "scale": 4 },
  "observed_at": { "value": "1893456000123", "unit": "ms", "source": "venue" }
}

Эти структуры похожи, но равенство полей ещё не доказывает равенство смысла. До написания конвертера я фиксирую инварианты:

  1. base/quote нельзя молча поменять местами;
  2. mantissa × 10^-scale должно давать то же рациональное значение без промежуточного float;
  3. единица времени является данными, а не знанием, спрятанным в имени переменной;
  4. отсутствующее значение не превращается в ноль;
  5. исходная запись остаётся доступной по хэшу для повторной проверки;
  6. любой отказ имеет стабильный код причины.

2. Конвертер должен возвращать результат или типизированный отказ

Вместо функции, которая «всегда» выдаёт объект v2, полезнее использовать явный результат:

convertV1(record):
  if record.schema != 1:
    return REJECT("SCHEMA_UNEXPECTED")

  parts = splitExactlyOnce(record.symbol, "_")
  if parts is invalid:
    return REJECT("INSTRUMENT_AMBIGUOUS")

  if record.decimals < 0 or record.decimals > 18:
    return REJECT("SCALE_OUT_OF_RANGE")

  if record.venue_time_ms is not an integer:
    return REJECT("TIME_NOT_INTEGER")

  return ACCEPT(v2Record, evidence)

evidence здесь не декоративный лог. Минимальный конверт может содержать raw_hash, converter_build, mapping_version, input_schema, output_schema и reason_code. Тогда спор о результате сводится к воспроизводимому запуску, а не к догадке о том, «какой код тогда работал».

3. Shadow write: новая версия пишет, но ещё не управляет ответом

На первом этапе основной путь продолжает читать v1. Параллельный обработчик строит v2 и сохраняет его отдельно. Пользовательский ответ от v2 пока не зависит.

Это даёт три преимущества:

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

Сравнивать сериализованный JSON побайтно недостаточно: порядок ключей и формат представления могут отличаться при равном смысле. Я строю каноническое представление:

fingerprint = sha256(
  base + "|" + quote + "|" +
  canonical_mantissa + "|" + scale + "|" +
  observed_at_ms + "|" + source_id
)

Если отпечатки расходятся, запись попадает в журнал расхождений вместе с категорией: IDENTITY_DIFF, VALUE_DIFF, TIME_DIFF, NULLABILITY_DIFF или UNCLASSIFIED_DIFF. Последняя категория должна считаться ошибкой классификатора, а не удобной корзиной для всего непонятного.

4. Dual read: читать обе версии и не маскировать конфликт

После стабильного shadow-периода клиент может читать обе записи. Но правило «если v2 есть, берём v2» слишком слабое. Нужен детерминированный приоритет:

if v2 is valid and fingerprints agree:
  return v2, state = "CONSISTENT_V2"

if v2 is missing and v1 is valid:
  return v1, state = "FALLBACK_V1"

if v1 and v2 are valid but fingerprints differ:
  return no_value, state = "SCHEMA_CONFLICT"

if neither is valid:
  return no_value, state = "UNAVAILABLE"

Ключевой выбор — не скрывать SCHEMA_CONFLICT под видом успешного fallback. Возврат старого значения может выглядеть надёжно, но лишает команду сигнала о том, что две версии уже описывают разные факты. Для информационного интерфейса честное «не знаю» часто безопаснее уверенного, но недоказуемого числа.

5. Cutover определяется условиями, а не датой в календаре

Я бы не переключал основной путь только потому, что новая схема прожила неделю. Набор условий должен быть проверяемым:

  • все обязательные источники прошли синтетические и replay-тесты;
  • нулевая доля неклассифицированных расхождений;
  • известные расхождения имеют владельца и срок решения;
  • fallback v1 проверен отдельной инъекцией отказа;
  • метрики v1 и v2 используют одну и ту же выборку;
  • остановка записи v2 не влияет на чтение v1;
  • откат не требует обратного преобразования уже изменённых строк.

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

6. Один переключатель недостаточен

Полезно разделить управление как минимум на четыре флага:

write_v2(source)
compare_v1_v2(source)
read_v2(source, consumer)
require_v2(source, consumer)

Так можно включить новую запись для одного источника, сравнение — для следующего, а чтение — только для внутреннего потребителя. Глобальный use_new_schema=true смешивает независимые решения и делает откат грубым.

Флаг тоже является частью аудита. У изменения должны быть версия конфигурации, время активации, инициатор и причина. Иначе две одинаковые записи, обработанные в соседние секунды, могут дать разные ответы без объяснения.

7. Минимальная матрица отказов

Для синтетического набора я проверяю не только «счастливый» пример:

Сценарий Ожидаемый результат
корректная v1 и эквивалентная v2 CONSISTENT_V2
v2 отсутствует FALLBACK_V1
перепутаны base и quote SCHEMA_CONFLICT / IDENTITY_DIFF
scale изменён с 4 на 6 без изменения mantissa SCHEMA_CONFLICT / VALUE_DIFF
секунды ошибочно помечены как миллисекунды SCHEMA_CONFLICT / TIME_DIFF
null преобразован в 0 SCHEMA_CONFLICT / NULLABILITY_DIFF
неизвестный формат символа REJECT / INSTRUMENT_AMBIGUOUS
остановлен shadow writer v1 остаётся доступной, сигнал v2 становится явным

Каждый тест должен проверять и значение, и код состояния, и состав evidence-конверта. Иначе реализация может вернуть правильное число по неправильной причине.

8. Завершение миграции — отдельная фаза

После cutover старую схему нельзя удалять немедленно. Сначала прекращается запись v1, затем выдерживается заявленное окно чтения, проверяются архивные replay-задачи и только потом удаляется старый путь. Само удаление оформляется как новая миграция с собственными критериями готовности.

Практический итог прост: безопасная смена схемы — это не одно преобразование, а временная система из двух представлений, измеряемого расхождения и явных состояний доверия. Shadow write позволяет наблюдать без влияния, dual read показывает конфликт, а раздельные флаги делают переход и откат управляемыми.

Границы и раскрытие. Все названия инструментов, значения, коды и пороги в тексте синтетические. Материал не утверждает, что описанная схема развёрнута в production ARMCP, и не является финансовой, инвестиционной или юридической рекомендацией. Черновая структура и языковая редактура подготовлены с помощью ИИ; автор проверил техническую логику, русский текст и несёт ответственность за публикацию.