{ "index": 230, "slug": "editorial-2021-08-mechanism-storage-contracts", "title": "Контракт хранилища: как менять запись, не ломая старого reader", "excerpt": "Совместимость хранилища зависит не только от JSON-синтаксиса. Разбираем presence, тип и смысл поля, безопасный порядок rollout и отрицательные проверки для writer/reader.", "contentHtml": "

Сбой reader после обычной записи часто выглядит как проблема базы: объект сохранился, но приложение не может его прочитать. Лог сообщает о неожиданном типе, отсутствующем поле или недопустимом значении. Ошибка кажется локальной. На деле она может затронуть все записи, которые создал новый writer.

\n

Цена неверного исправления выше одного исключения. Если reader молча подставит default, команда потеряет различие между старой записью без поля и новой записью, которая явно очистила его. Если writer переиспользует старое значение в новом смысле, откат кода не вернёт смысл уже записанных данных. Следующий сервис увидит ту же строку и интерпретирует её по-своему.

\n

Тезис статьи простой: контракт хранилища нужно проверять как договор между writer и reader. JSON задаёт форму передачи, но не описывает совместимость, presence и бизнес-смысл. Для безопасного изменения сначала расширяют reader, затем writer. Любое сужение допустимых значений, обязательности или смысла проходит отдельную проверку и не считается additive-изменением.

\n

Что именно входит в контракт

\n

Рассмотрим учебную запись профиля. Её ядро существует в версии v1:

\n
{\n  \"id\": \"profile-17\",\n  \"displayName\": \"Ada\",\n  \"settings\": {\"emailDigest\": \"weekly\"}\n}
\n

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

\n
Четыре слоя контракта profile.settings
СлойПравилоЧто проверяетЧего не доказывает
ФорматОбъект состоит из пар имя/значениеТекст можно разобратьСмысл и версии
Формаid — непустая строка; timezone — строка, null или отсутствуетТип и presenceСмысл старого значения
СовместимостьНовый optional key не ломает v1 readerПару writer/readerРеальный rollout
Семантикаweekly означает периодичность сводкиПереиспользование значенияРешение владельца предметной области
\n

Эти слои нельзя заменять друг другом. Валидный JSON может нарушать контракт. Строка может иметь правильный тип, но новый reader может понимать её иначе. Schema может разрешать отсутствие поля, но приложение обязано знать, означает ли оно «старый writer не сообщил значение», «значение неизвестно» или «пользователь очистил его».

\n

Absent, null и значение — разные состояния

\n

В учебном договоре отсутствие timezone означает, что v1 writer о нём не знал. null означает явную очистку. Непустая строка означает заданный часовой пояс. Эти состояния нельзя свести к одному JavaScript default.

\n
function readTimezone(record) {\n  if (!Object.prototype.hasOwnProperty.call(record, 'timezone')) {\n    return { state: 'absent' };\n  }\n\n  if (record.timezone === null) {\n    return { state: 'explicit-null' };\n  }\n\n  if (typeof record.timezone === 'string' && record.timezone.length > 0) {\n    return { state: 'value', value: record.timezone };\n  }\n\n  throw new Error('timezone must be absent, null, or a non-empty string');\n}
\n

Проверка собственного свойства важна. Значение из прототипа не является частью сохранённой записи. Пустая строка тоже не становится корректным значением только потому, что имеет тип string. В реальной системе допустимый справочник часовых поясов и его нормализация принадлежат владельцу поля. Этот пример проверяет границу договора, а не справочник и не реальное хранилище.

\n

Порядок ключей также не является контрактом. Reader должен обращаться к именам полей. Учебная проверка с переставленными ключами должна дать тот же результат:

\n
const reordered = {\n  settings: { emailDigest: 'weekly' },\n  timezone: 'Europe/Moscow',\n  displayName: 'Ada',\n  id: 'profile-17',\n};\n\nconst result = readProfile(reordered);\n// result.id === 'profile-17'
\n

Если библиотека или протокол специально фиксирует порядок, это отдельное правило конкретного формата. Его нельзя вывести из обычного JSON-объекта.

\n
\"Схема
Сначала reader учится читать старые и новые записи. Только после этого writer добавляет новый optional key.
\n

Безопасное additive-изменение

\n

Совместимым считается изменение, которое не запрещает ни одно прежнее корректное состояние и не меняет смысл существующего значения. Добавление optional timezone может быть таким изменением, если v1 reader игнорирует неизвестные optional fields, а v2 reader умеет читать запись без этого key.

\n

Направление важно. Новый writer может встретиться со старым reader. Старый writer может встретиться с новым reader. Мобильное приложение, очередь и фоновый job часто обновляются в разное время. Поэтому одного теста «новый код читает новую запись» недостаточно.

\n
Минимальная compatibility matrix для учебного контракта
WriterReaderОжидаемый результатПочему
v1v1acceptБазовая пара
v1v2accept, absentНовому reader не хватает только optional key
v2v2acceptОбе версии знают поле
v2v1accept только при tolerant readerСтарый reader должен игнорировать unknown optional key
v1v2 с required timezonerejectНовый reader сузил старый договор
\n

В последней строке форма записи ещё похожа на прежнюю, но совместимость уже нарушена. Старый writer не мог передать обязательный для нового reader key. Название «v2» не исправляет эту проблему.

\n

Отрицательный путь нельзя прятать

\n

Положительный тест показывает, что happy path работает. Отрицательный тест показывает, где система должна остановиться. Для этого контракта нужны как минимум четыре отказа.

\n\n

Последний случай особенно опасен. Тип, имя и набор символов могут остаться теми же. Форматный валидатор даст зелёный результат, хотя два потребителя примут разные решения. Такой дефект нельзя исправить выбором другого JSON parser.

\n

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

\n
Маршрут первичной диагностики
СимптомПричинаПроверкаДействие
timezone имеет числоType breakСверить фактический type и версию writerОстановить создание новых неправильных записей; исправить validator
Старая запись не содержит keyPresence breakПрогнать v1 sample через v2 readerВернуть ветку absent; не записывать null поверх evidence
daily отвергнутNarrowingСравнить старый список значений с новымРасширить reader или остановить rollout; не переписывать legacy массово
weekly даёт другой эффектSemantic breakСверить owner и описание смысла поляОстановить producer; ввести новое поле или отдельный переход
Новая запись читается, старая — нетReader проверяет только v2 shapeПроверить обе стороны matrixСначала сделать reader tolerant, затем менять writer
\n

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

\n
  1. Сохранить безопасное evidence: id записи, версии writer и reader, список ключей, путь ошибки и состояние спорного поля. Не писать в общий лог весь профиль.
  2. Назвать владельца поля и зафиксировать смысл. Для timezone отдельно описать absent, null, непустую строку и неверный type.
  3. Собрать samples старого writer, нового writer, старого reader и нового reader. Проверить направление «старый writer → новый reader».
  4. Если изменение additive, выпустить tolerant reader до writer. Проверить чтение старых записей и новой записи старым reader.
  5. Добавить отрицательные samples для type break, required field, narrowing и semantic change. У каждого sample должен быть ожидаемый accept или reject.
  6. Если producer продолжает создавать записи, которые активный reader не понимает, остановить producer доступным в конкретной системе способом. Это может быть release, конфигурация, очередь или право записи.
  7. Разделить rollback кода и rollback данных. Для нового optional key возврат writer не обязан удалять уже записанное поле. Для смены смысла одного key простой откат бинарника не восстанавливает прежнюю семантику.
  8. После локального теста добавить integration-проверку выбранного validator, storage или broker. Учебная fixture сама по себе не доказывает их поведение.
\n

Ограничения и критерий готовности

\n

Эта модель не выбирает базу, брокер или schema registry. Она не обещает, что любой storage engine игнорирует неизвестные поля. Она не учитывает retention, backfill, права, размер записи, транзакции, репликацию и задержки. Эти свойства проверяются отдельно на реальном выбранном компоненте.

\n

Примеры в статье учебные. Они не запускают production reader, не выполняют миграцию, не останавливают сервис и не содержат production-метрик. Поэтому нельзя заявлять, что конкретный rollout уже безопасен. Безопасность появляется после проверки реальных samples и матрицы поддерживаемых пар.

\n

Критерий готовности проверяем: каждая поддерживаемая пара writer/reader имеет явный verdict, v1 запись проходит новый reader, optional v2 поле не ломает старый reader, отрицательные samples отклоняются в ожидаемых местах, а для смены смысла назначен отдельный владелец перехода. Если хотя бы одно условие неизвестно, изменение ещё не готово к rollout.

\n

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

\n" }