DarkRiDDeR14 мин

Данные. Контракт хранилища: как менять profile/settings без внезапного разрыва

ДанныеИнфраструктураПрактика

Проблема начинается не в момент большой миграции, а после небольшой записи. Producer добавил поле timezone в profile/settings, reader увидел незнакомое состояние или решил, что отсутствующее поле равно null. Цена такой догадки — не красивый exception, а уже записанная версия профиля, которую старый код трактует иначе. Затем команда спорит о формате, хотя причина лежит в неописанном договоре между тем, кто пишет, и тем, кто читает.

Для августа 2021 года я бы не называл JSON контрактом. JSON даёт синтаксис объекта, но не владельца поля, не смысл строки, не срок поддержки старого reader и не порядок отката. В этой заметке договор строится вокруг одного нейтрального record profile.settings. Пример локальный: он работает только в памяти и не моделирует БД, файл, broker, репликацию или реальную миграцию.

Сначала записываем границу договора

У договора есть объект и владелец. Объект отвечает на вопрос, что именно меняется: в нашем случае настройки одного профиля, а не «пользовательские данные вообще». Владелец отвечает на другой вопрос: кто разрешает новый смысл поля, совместимость старого reader и момент, когда старый writer можно выключить. Если это не записано рядом со схемой, любое изменение выглядит локальным до первого consumer, который был собран раньше.

Минимум полезных частей: стабильный идентификатор, обязательные поля, необязательные поля, состояние absent, значение null, допустимые значения и человеческий смысл. Для emailDigest недостаточно написать «строка». В нашем договоре это режим частоты сводки: off, weekly или daily. Если завтра тем же словом начинают помечать маркетинговый сегмент, тип остаётся строкой, а смысл уже сломан.

const profileSettingsContract = {
  owner: 'profile-settings',
  identity: 'profile.id',
  required: ['id', 'displayName', 'settings.emailDigest'],
  optional: { timezone: 'absent | null | non-empty string' },
  meaning: { emailDigest: 'summary cadence, not a marketing label' },
  readers: ['v1 ignores optional unknown fields', 'v2 distinguishes absent and null'],
  rollout: 'reader capability → additive writer → compatibility test',
};
Минимальная карточка договора profile.settings
ЧастьКто задаёт правилоЧто проверяем до записиЧто не обещает правило
idowner recordнепустая строка и связь с одним профилемчто профиль существует в выбранном хранилище
settings.emailDigestowner значения и их смыслазначение входит в записанный наборчто строка сама раскрывает бизнес-смысл
timezoneowner presence semanticsabsent, null или непустая строка различенычто absent можно бездумно заменить на null
версия reader/writerвладелец rolloutcompatibility matrix покрывает поддерживаемые парычто любой старый consumer узнает новые поля

Таблица намеренно не называет конкретный storage. Контракт живёт выше него: одна реализация может держать запись в документе, другая в строке или в сообщении. Смена места не отменяет правила чтения. И наоборот: выбранный сериализатор не делает смысл поля проверяемым. Поэтому owner должен хранить не только схему, но и список поддерживаемых направлений: старый writer → новый reader, новый writer → старый reader и новая пара.

Absent и null — два разных входа

Необязательное поле имеет минимум три состояния: ключ не пришёл, ключ пришёл с null, ключ пришёл со значением. Для старого writer отсутствие timezone означает только то, что он её не передал. Это не разрешение подставить «часовой пояс очищен». null в данной модели, наоборот, является явной командой очистки. Если приложение выбирает другую семантику, её нужно записать и проверить тем же способом.

const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);

function timezoneState(record) {
  if (!owns(record, 'timezone')) return 'absent';
  if (record.timezone === null) return 'explicit-null';
  if (typeof record.timezone === 'string' && record.timezone) return 'value';
  throw new Error('timezone violates contract');
}

timezoneState({ id: 'profile-17' });                 // absent
timezoneState({ id: 'profile-17', timezone: null }); // explicit-null

В JavaScript важно проверять наличие собственного ключа, а не правдивость значения. Условие if (record.timezone) смешает пустую строку, null и отсутствие поля; в нашем договоре все три случая требуют разных решений. Пустая строка не допускается вовсе, потому что она не обозначена как отдельное состояние. Такой запрет полезнее умного fallback: reader либо видит известный случай, либо останавливает интерпретацию и оставляет evidence для исправления.

Схема совместимости profile.settings: writer v1 и writer v2, reader v1 и reader v2; v2 добавляет необязательное timezone, старый reader игнорирует его, новый reader различает absent, null и строковое значение
Additive-поле безопасно только для явно проверенной пары reader и writer. Стрелки на схеме не являются гарантией выбранного хранилища.

Поддержка старых чтений и записей — это матрица, а не надежда

Перед rollout стоит назвать пары, которые действительно будут жить одновременно. В fixture writer v1 пишет только core-поля. Writer v2 добавляет timezone как optional. Reader v1 читает именованные core-поля и игнорирует неизвестный optional ключ. Reader v2 умеет прочитать старую запись и вернуть состояние absent, не изображая его очищенным значением. Именно эти четыре направления становятся тестом, а не устным обещанием.

const matrix = [
  ['writer v1', 'reader v1', 'accept'],
  ['writer v1', 'reader v2', 'accept: timezone absent'],
  ['writer v2 additive', 'reader v1', 'accept: unknown optional ignored'],
  ['writer v2', 'reader v2', 'accept: null is explicit clear'],
  ['writer v1 daily', 'narrowed reader', 'reject before rollout'],
];

Здесь есть важная граница: «игнорировать неизвестное» допустимо только для поля, которое не меняет старое обязательное поведение. Нельзя добавить новое поле, а затем сделать старый emailDigest зависимым от него без обновления reader. Тогда поле выглядит additive по форме, но становится semantic break. Так же нельзя сузить множество старых значений: если v1 писал daily, новый reader, который принимает только off и weekly, обязан быть отклонён migration test до релиза.

Безопасный rollout идёт от reader к writer

Порядок короткий. Сначала фиксируем старый договор и примеры старых записей. Затем добавляем reader, который понимает старый record и новое optional поле. Только после этого writer начинает посылать новое поле. Пока старый reader остаётся в поддерживаемой матрице, writer не должен превращать optional поле в обязательное и не должен менять смысл существующих значений. Удаление старого пути — отдельное изменение: для него нужны данные, что соответствующей пары больше нет, а не просто новая дата в схеме.

Порядок изменения без предположения о платформе
ШагИзменениеПроверкаСтоп-сигнал
1зафиксировать v1 и samplesv1 reader читает каждый sampleнепонятен смысл или owner старого поля
2добавить v2 readerv1 record даёт timezone: absentreader подставляет null без правила
3добавить v2 writerv1 reader читает core, v2 reader читает значение и clearновое поле стало обязательным для старого пути
4предложить удаление legacymatrix не содержит поддерживаемую старую паруесть record или consumer вне доказанной матрицы

Rollback тоже должен знать границу. Если writer только добавил optional поле, можно остановить его выпуск и оставить reader совместимым с уже появившимися record. Если writer переиспользовал существующее значение с новым смыслом, простая отмена кода не возвращает старое значением прежний смысл. В таком случае сначала останавливают producer, сохраняют примеры и запускают отдельный контролируемый перевод данных. В нашем локальном примере такого перевода нет; он специально не выдаётся за готовую migration procedure.

Fixture превращает правило в проверяемую границу

Функция runStorageContractFixture() создаёт v1, additive v2 и v2 с явным null. Она читает их двумя consumer, переставляет ключи объекта, проверяет matrix и намеренно предлагает три плохих reader: с narrowed набором emailDigest, со сменой смысла и с обязательным timezone. У каждой гарантии есть assertion. Это не проверка сериализатора и не тест выбранной БД; это маленькое место, где изменение договора получает наблюдаемый ответ.

const fixture = runStorageContractFixture();
for (const [name, passed] of Object.entries(fixture.assertions)) {
  if (!passed) throw new Error('failed invariant: ' + name);
}

// Fixture использует только Array, Map и объекты в памяти.
// Она не проверяет выбранное хранилище, репликацию или реальный rollout.

Отдельный плюс fixture — она защищает от красивых, но пустых слов. Нельзя сказать, что generic JSON «эволюционно совместим»: код принимает или отклоняет только правила, которые мы сами описали. Нельзя сказать, что объект упорядочен как contract: reader обращается к именам полей, а перестановка ключей даёт тот же результат. Нельзя сказать, что null равен отсутствию: обе ветви возвращают разные состояния. Когда правило меняется, рядом меняется assertion и matrix.

Маршрут: симптом → причина → проверка → действие

  1. Зафиксировать симптом: какой reader, какой record id, какая пара writer/reader и на каком поле разошлась интерпретация. Не начинать с массового переписывания данных.
  2. Назвать owner и ожидаемый смысл поля. Для timezone отдельно ответить, что означают absent, null и строка.
  3. Проверить текущую compatibility matrix на старых и новых samples. Особо проверить направление старый writer → новый reader.
  4. Если изменение additive, сначала выпустить reader, затем writer. Если поле становится required или меняет смысл, остановить rollout и оформить отдельный договор перехода.
  5. Добавить failing sample для narrowing, type break и semantic break. Без отрицательного примера тест проверяет только удачный путь.
  6. Удалять старую поддержку только после явной границы поддержки. До этого сохранять reader, который понимает уже записанные record.

Пределы модели и источники

RFC 8259 описывает JSON-объект как набор пар имя/значение и предупреждает о различиях реализации вокруг порядка членов. Поэтому порядок ключей в этой статье не является договором. JSON Schema draft 2019-09 полезен как язык structural assertions, но выбор валидатора и семантика приложения остаются с владельцем record. Apache Avro показывает другой, форматно-зависимый случай: у него есть writer и reader schema resolution. Нельзя переносить это правило на любой JSON лишь потому, что оба примера выглядят как данные.

В пакете не запускаются storage engine, реальные файлы, сети, schema registry, миграционный job, browser, CI или deployment. Нет SLA, production-метрик и заявления, что конкретный rollout уже происходил. Следующий практический шаг — взять один настоящий record без чувствительных значений, записать его owner и поддерживаемые направления чтения, затем перенести эти samples в локальный compatibility test. После этого можно обсуждать конкретное хранилище, а не наоборот.

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

  • RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — первичный стандарт JSON: объект состоит из пар имя/значение; порядок членов объекта не следует использовать как межсистемный договор
  • JSON Schema draft 2019-09: Validation vocabulary — официально опубликованный Draft 2019-09 (17 сентября 2019, не IETF RFC) разделяет assertions для структуры и допускает тип null; конкретный валидатор и режим проверки остаются выбором приложения
  • Apache Avro 1.10.2: Specification, schema resolution — официальная спецификация формата описывает resolution writer и reader schema; это пример форматно-зависимого правила, а не свойство произвольного JSON-объекта