{ "index": 229, "slug": "editorial-2021-08-field-storage-contracts", "title": "Reader упал после записи: как проверить контракт хранилища", "excerpt": "Практический разбор сбоя после изменения записи: как отличить неверный тип, отсутствие поля, сужение старых значений и смену смысла, не уничтожить evidence и вернуть дефект в compatibility test.", "contentHtml": "

Reader падает сразу после записи profile.settings. В логе появляется unexpected value. Producer уже выпустил новую версию записи, а старый consumer не знает, что с ней делать. Ошибка стоит дороже одного красного запроса: поспешный default, массовая перезапись или бездумный rollback могут стереть разницу между отсутствующим полем, явным null и новым смыслом старой строки. После этого нельзя точно сказать, что записал producer и что именно сломал reader.

\n

Главный тезис прост: контракт хранилища нужно проверять как пару writer/reader на конкретных образцах. Название v2 ничего не гарантирует. Совместимость сохраняется только там, где известны обязательные поля, допустимые значения, смысл каждого значения и поведение при старой записи. Сначала сохраняют безопасное evidence. Потом классифицируют разрыв. И только после этого выбирают исправление.

\n

Что считать контрактом

\n

Запись — это не только набор ключей и типов. Контракт включает имя поля, его наличие, допустимые значения, семантику этих значений и реакцию reader на неизвестное поле. Для profile.settings можно зафиксировать небольшой договор: id, displayName и settings обязательны; timezone добавляется как optional; emailDigest принимает только off, weekly и daily. Старый reader может игнорировать новый optional key, но не обязан угадывать смысл существующего key.

\n

Совместимость имеет направление. Reader v1 читает запись writer v2 только если новый key не меняет обязательный core и старый reader безопасно игнорирует добавление. Reader v2 читает запись writer v1 только если он умеет обработать отсутствие нового optional key. Это две разные проверки. Успешное чтение в одну сторону не доказывает успех в другую.

\n

Сначала собираем evidence

\n

До изменения записи или конфигурации сохраните идентификатор записи, label версии writer, имя reader, путь ошибки, список ключей и состояние спорного поля. Значения профиля не нужны для первой классификации. Для персональных данных применяйте redaction и действующую политику доступа. Цель — восстановить форму записи и ожидаемую пару версий, а не скопировать содержимое в общий лог.

\n
const owns = (value, key) =>\n  Object.prototype.hasOwnProperty.call(value, key);\n\nfunction collectEvidence(record, error) {\n  const timezoneState = !owns(record, 'timezone')\n    ? 'absent'\n    : record.timezone === null\n      ? 'explicit-null'\n      : typeof record.timezone;\n\n  return {\n    recordId: record.id,\n    writer: record.schema,\n    fieldNames: Object.keys(record).sort(),\n    errorPath: error.path,\n    timezoneState,\n  };\n}\n\n// Не выводим значения profile и адреса в общий лог.
\n

Сортировка ключей нужна для стабильного вывода. Она не задаёт порядок чтения. JSON object не должен использовать порядок членов как межсистемный договор. Если перестановка ключей меняет результат, consumer зависит от свойства, которого формат не обещает. Это отдельная ошибка, даже если все типы выглядят правильно.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
timezone содержит числоtype breakКлюч есть, тип — numberОстановить writer для такого значения и исправить проверку. Не подставлять строку наугад.
Старая запись без timezone отвергнутаpresence breakКлюч отсутствует в образце v1Вернуть ветку absent. Не записывать null поверх старых данных.
Старое daily больше не читаетсяnarrowingLegacy writer создавал это допустимое значениеРасширить reader или откатить его договор. Не переписывать записи массово.
weekly читается с другим эффектомsemantic breakФорма и тип прежние, смысл изменилсяОстановить producer и выделить новый key или управляемый переход.
\n

Разделяем отсутствие, null и ошибочное значение

\n

Три состояния часто ошибочно сводят к одному default. Но у них разные причины и разные действия. Отсутствующий timezone означает, что старый writer не передал настройку. timezone: null может означать явную очистку, если это прописано в договоре. timezone: 3 нарушает тип. Reader должен различать состояния, иначе rollback начнёт менять историю данных.

\n
const oldRecord = {\n  id: 'profile-17',\n  settings: { emailDigest: 'weekly' },\n};\n\nconst clearRecord = { ...oldRecord, timezone: null };\nconst brokenRecord = { ...oldRecord, timezone: 3 };\n\nreadProfileV2(oldRecord).timezone.state;\n// 'absent'\nreadProfileV2(clearRecord).timezone.state;\n// 'explicit-null'\nreadProfileV2(brokenRecord);\n// throws: timezone must be a string or null
\n

Учебный пример работает только с объектами в памяти. Он показывает требуемые состояния, но не проверяет конкретную базу, сериализатор, репликацию, права, retention или скорость обработки. В реальном сервисе те же образцы нужно пропустить через настоящий reader и validator. Локальный пример нельзя выдавать за гарантию всей системы.

\n

Если текущий reader не различает эти ветки, исправьте договор или reader до изменения записей. Перезапись absent в null придумывает факт явной очистки. Замена числа на строку придумывает значение. Оба действия уничтожают исходный сигнал и усложняют расследование следующего сбоя.

\n
\"Дерево
К действию переходят после классификации разрыва. Остановка producer не означает удаление уже записанных данных.
\n

Когда останавливать producer

\n

Producer нужно остановить, если он продолжает создавать записи, которые активный reader не может безопасно интерпретировать. При type break это ограничивает число новых ошибочных записей. При semantic break это прекращает смешение старого и нового смысла под одним key. Механизм остановки зависит от системы: release, конфигурация, очередь или права. Статья не приписывает ей универсальный рубильник.

\n

Для additive change остановка может не понадобиться. Если compatibility matrix доказывает, что старый reader игнорирует новый optional key, writer может продолжить работу. Но это решение следует из проверки пары версий, а не из слова optional в схеме. Если зелёной пары нет, безопаснее остановить рост спорных записей до восстановления reader или подготовки перехода.

\n
Выбор действия без потери исходного значения
УсловиеДопустимое действиеСохраняемНе делаем
Добавлен optional keyОставить tolerant reader и при необходимости остановить writerСтарый и новый образцыНе удалять key из всех записей без правила
Reader сузил emailDigestВернуть прежнее допустимое множество или readerОбразец с daily и verdictНе заменять daily другим значением массово
Существующее значение получило новый смыслОстановить producer и спроектировать переходСтарый и новый смысл, owner решенияНе считать rollback кода rollback данных
Значение имеет неверный типЗаблокировать такой путь writer и исправить validatorОшибочный образец и error pathНе маскировать ошибку fallback-строкой
\n

Почему rollback кода не откатывает смысл

\n

Если v2 добавила независимый optional key, возврат бинарника обычно не требует удаления новых записей. Старый reader может читать прежний core, а новый reader — core и дополнительный key. Но если producer начал использовать weekly в новом смысле, запись не содержит метки, которая восстановит старую трактовку. Возврат старого кода прочитает ту же строку и снова придаст ей старый смысл. Это не возвращает данные в прошлое.

\n

При semantic break нужны остановка producer, сохранение evidence и решение владельца данных. Иногда нужен новый key с новой семантикой. Иногда — явная миграция с версиями и обратимым этапом. Нельзя обещать snapshots, транзакции или реплики, если их свойства конкретной системы не проверены. Нельзя заменять это решение скрытым cleanup.

\n

Возвращаем случай в compatibility test

\n

Каждый найденный разрыв должен стать фиксированным образцом и проверкой ожидаемого результата. Для type break добавьте timezone: 3 и ожидайте rejection. Для старой записи без поля ожидайте absent. Для narrowing сохраните legacy daily и запретите reader, который его отвергает. Для semantic break зафиксируйте старый смысл и ожидайте остановки до отдельного решения.

\n
const cases = [\n  ['v1 record without timezone', oldRecord, 'accept'],\n  ['explicit clear', clearRecord, 'accept'],\n  ['wrong timezone type', brokenRecord, 'reject'],\n];\n\nfor (const [name, record, expected] of cases) {\n  const actual = tryRead(record);\n  if (actual !== expected) {\n    throw new Error(`${name}: expected ${expected}, got ${actual}`);\n  }\n}\n\n// Это проверка контракта на образцах, не интеграция со storage.
\n

Матрица должна содержать зелёные и красные пары. Одни успешные примеры показывают только то, что reader умеет принять известный input. Отрицательный пример доказывает, что опасная правка действительно остановится. Добавили правило — добавили sample и assertion. Изменили смысл — изменили контракт явно, а не только комментарий.

\n

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

\n
  1. Зафиксируйте record id, writer, reader, error path, список ключей и состояние спорного поля без вывода чувствительных значений.
  2. Сравните активную пару writer/reader с ожидаемым контрактом и сохраните исходный образец до любой перезаписи.
  3. Классифицируйте разрыв: type, absence/null, narrowing или semantic. Не называйте отсутствие поля ошибкой типа.
  4. Проверьте, зависит ли результат от порядка ключей. Если зависит, уберите такую зависимость из reader.
  5. Если producer продолжает создавать несовместимые записи, остановите его доступным для системы способом.
  6. Выберите действие: восстановить reader, вернуть допустимое множество, исправить validator или выделить новый key для нового смысла.
  7. Добавьте старый, новый и отрицательный образцы в compatibility matrix. Зафиксируйте ожидаемые accept и reject.
  8. Повторите локальную проверку, затем отдельно выполните integration test настоящего формата и хранилища.
\n

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

\n

Эта схема не выбирает формат хранения и не делает JSON Schema универсальной политикой миграции. JSON Schema проверяет структурные утверждения, но не знает, какое бизнес-значение у строки и какие writer ещё живут в системе. Avro имеет собственные правила разрешения writer и reader schema, но они относятся к Avro, а не автоматически к произвольному JSON object. Реальное хранилище добавляет свои свойства: атомарность, репликацию, доступ, retention, лимиты и порядок доставки.

\n

Разбор готов, когда команда может повторить его на обезличенном образце и получить тот же verdict. Для каждой поддерживаемой пары есть базовый sample, additive-case и отрицательный case. Старый reader принимает только доказанно безопасную новую запись. Новый reader обрабатывает старую запись без обязательного поля. Invalid type, narrowing и semantic change дают ожидаемый отказ или отдельный контролируемый переход. В журнале остаются writer, reader, error path и owner решения. Production-эффект из учебных примеров не следует.

\n

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

\n" }