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" }
}
Эти структуры похожи, но равенство полей ещё не доказывает равенство смысла. До написания конвертера я фиксирую инварианты:
base/quoteнельзя молча поменять местами;mantissa × 10^-scaleдолжно давать то же рациональное значение без промежуточногоfloat;- единица времени является данными, а не знанием, спрятанным в имени переменной;
- отсутствующее значение не превращается в ноль;
- исходная запись остаётся доступной по хэшу для повторной проверки;
- любой отказ имеет стабильный код причины.
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, и не является финансовой, инвестиционной или юридической рекомендацией. Черновая структура и языковая редактура подготовлены с помощью ИИ; автор проверил техническую логику, русский текст и несёт ответственность за публикацию.
