{ "index": 230, "slug": "editorial-2021-08-mechanism-storage-contracts", "title": "Контракт хранилища: как менять запись, не ломая старого reader", "excerpt": "Совместимость хранилища зависит не только от JSON-синтаксиса. Разбираем presence, тип и смысл поля, безопасный порядок rollout и отрицательные проверки для writer/reader.", "contentHtml": "
Представим выкладку версии v2: новый writer уже сохранил запись, а один из старых reader всё ещё разбирает её по правилам v1. Объект физически лежит в хранилище, но приложение получает ошибку неожиданного типа, отсутствующего поля или недопустимого значения. Сбой выглядит локальным, хотя затрагивает все записи, которые создал новый writer.
\nЦена неверного исправления выше одного исключения. Если reader молча подставит default, команда потеряет различие между старой записью без поля и новой записью, которая явно очистила его. Если writer переиспользует старое значение в новом смысле, откат кода не вернёт смысл уже записанных данных. Следующий сервис увидит ту же строку и интерпретирует её по-своему.
\nКонтракт хранилища нужно проверять как договор между writer и reader. JSON задаёт форму передачи, но не описывает совместимость, presence и бизнес-смысл. Для безопасного изменения сначала расширяют reader, затем writer. Любое сужение допустимых значений, обязательности или смысла проходит отдельную проверку и не считается additive-изменением.
\nРассмотрим учебную запись профиля. Её ядро существует в версии v1:
\n{\n \"id\": \"profile-17\",\n \"displayName\": \"Ada\",\n \"settings\": {\"emailDigest\": \"weekly\"}\n}\nВ v2 команда хочет добавить часовой пояс. На первый взгляд достаточно дописать timezone. Но нужно зафиксировать четыре разных правила.
| Слой | Правило | Что проверяет | Чего не доказывает |
|---|---|---|---|
| Формат | Объект состоит из пар имя/значение | Текст можно разобрать | Смысл и версии |
| Форма | id — непустая строка; timezone — строка, null или отсутствует | Тип и presence | Смысл старого значения |
| Совместимость | Новый optional key не ломает v1 reader | Пару writer/reader | Реальный rollout |
| Семантика | weekly означает периодичность сводки | Переиспользование значения | Решение владельца предметной области |
Эти слои нельзя заменять друг другом. Валидный JSON может нарушать контракт. Строка может иметь правильный тип, но новый reader может понимать её иначе. В JSON Schema required отвечает за наличие имени, а type — за допустимый тип его значения. Эти keywords не решают, означает ли отсутствие поля «старый writer не сообщил значение», «значение неизвестно» или «пользователь очистил его».
RFC 8259 описывает JSON object как набор пар и рекомендует уникальные имена. При дубликатах разные реализации могут оставить последнее значение, сообщить об ошибке или сохранить все пары. Поэтому граница контракта должна отдельно решить, как обнаруживаются дубликаты; одного успешного разбора JSON недостаточно.
\nВ учебном договоре отсутствие timezone означает, что v1 writer о нём не знал. null означает явную очистку. Непустая строка означает заданный часовой пояс. Это правило данного договора, а не универсальное значение null во всех системах.
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. В реальной системе допустимый справочник часовых поясов и его нормализация принадлежат владельцу поля. Этот пример проверяет границу договора, а не справочник и не реальное хранилище.
Порядок ключей также не является контрактом для обычного JSON object. Reader должен обращаться к именам полей. Выполните этот фрагмент после функции readTimezone из предыдущего примера:
function readProfile(record) {\n if (!record || typeof record !== 'object' || Array.isArray(record)) {\n throw new Error('record must be an object');\n }\n\n if (typeof record.id !== 'string' || record.id.length === 0) {\n throw new Error('id must be a non-empty string');\n }\n\n return { id: record.id, timezone: readTimezone(record) };\n}\n\nconst reordered = {\n settings: { emailDigest: 'weekly' },\n timezone: 'Europe/Moscow',\n displayName: 'Ada',\n id: 'profile-17',\n};\n\nconst result = readProfile(reordered);\nconsole.assert(result.id === 'profile-17');\nconsole.assert(result.timezone.state === 'value');\nДва console.assert проверяют именно этот пример: функция находит id и различает заданное значение часового пояса, хотя ключи записаны в другом порядке. Если библиотека или протокол специально фиксирует порядок, это отдельное правило конкретного формата. Его нельзя вывести из обычного JSON object.
Совместимым считается изменение, которое не запрещает ни одно прежнее корректное состояние и не меняет смысл существующего значения. Добавление optional timezone может быть таким изменением, если v1 reader игнорирует неизвестные optional fields, а v2 reader умеет читать запись без этого key.
Направление важно. Новый writer может встретиться со старым reader. Старый writer может встретиться с новым reader. Мобильное приложение, очередь и фоновый job часто обновляются в разное время. Поэтому одного теста «новый код читает новую запись» недостаточно.
\n| Writer | Reader | Ожидаемый результат | Почему |
|---|---|---|---|
| v1 | v1 | accept | Базовая пара |
| v1 | v2 | accept, absent | Новый reader допускает отсутствие optional key |
| v2 | v2 | accept | Обе версии знают поле |
| v2 | v1 | accept, если v1 reader игнорирует unknown optional key | Иначе старый reader должен отклонить новую форму |
| v1 | v2 с required timezone | reject | Новый reader сузил старый договор |
В последней строке форма записи ещё похожа на прежнюю, но совместимость уже нарушена. Старый writer не мог передать обязательный для нового reader key. Название «v2» не исправляет эту проблему. Для Avro похожий вопрос решается специальным алгоритмом schema resolution; это свойство Avro, а не общее обещание JSON.
\nПоложительный тест показывает, что happy path работает. Отрицательный тест показывает, где система должна остановиться. Для этого контракта нужны как минимум четыре отказа.
\ntimezone: 3 не превращается в случайную строку через fallback.timezone от v1 записи.daily.weekly не получает новый смысл «маркетинговый сегмент» без нового поля или явного перехода.Последний случай особенно опасен. Тип, имя и набор символов могут остаться теми же. Форматный валидатор даст зелёный результат, хотя два потребителя примут разные решения. Такой дефект нельзя исправить выбором другого JSON parser.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
timezone имеет число | Type break | Сверить фактический type и версию writer | Остановить создание новых неправильных записей; исправить validator |
| Старая запись не содержит key | Presence 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 |
timezone отдельно описать absent, null, непустую строку и неверный type.Эта модель не выбирает базу, брокер или schema registry. Она не обещает, что любой storage engine игнорирует неизвестные поля. Она не учитывает retention, backfill, права, размер записи, транзакции, репликацию и задержки. Эти свойства проверяются отдельно на реальном выбранном компоненте.
\nПримеры в статье учебные. Они не запускают production reader, не выполняют миграцию, не останавливают сервис и не содержат production-метрик. Поэтому нельзя заявлять, что конкретный rollout уже безопасен. Безопасность появляется после проверки реальных samples и матрицы поддерживаемых пар.
\nКритерий готовности проверяем: каждая поддерживаемая пара writer/reader имеет явный verdict, v1 запись проходит новый reader, optional v2 поле не ломает старый reader, отрицательные samples отклоняются в ожидаемых местах, а для смены смысла назначен отдельный владелец перехода. Если хотя бы одно условие неизвестно, изменение ещё не готово к rollout.
\nrequired и type; это не готовая политика rollout.