DarkRiDDeR15 мин

Разбор: reader упал после записи — как диагностировать контракт хранилища

ДанныеОтладкаНадёжность

Симптом выглядит коротко: reader упал сразу после записи profile/settings. В логе видно «unexpected value», а у producer уже есть новая версия. Цена поспешного исправления — не только повторный сбой. Если сейчас переписать record, подставить default или включить прежний код без проверки, можно стереть различие между отсутствующим key, явным null и новым смыслом старой строки. Тогда откат станет похож на исправление, но потеряет evidence.

Ниже — полевой маршрут для учебного record, а не отчёт о production-инциденте. Он помогает разделить четыре причины: type break, presence break, narrowing и semantic change. Моя fixture работает в одном Node-процессе с Array, Map и objects. Она не читает реальное хранилище и не умеет останавливать service; слова «остановить producer» здесь означают безопасное действие, которое владелец конкретной системы должен выполнить своими средствами после проверки границы.

Сначала сохраняем evidence, а не меняем запись

Для первого диагноза нужны record id, label версии writer, имя reader, путь ошибки, перечень key и состояние спорного поля. Сами значения профиля не обязательны и часто не должны попадать в общий лог. Если ошибка на timezone, достаточно различить absent, null, строку и неверный type. Если ошибка на emailDigest, нужен старый список допустимых значений и смысл, который reader ожидал. Это даёт проверяемую гипотезу до rollback.

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

function collectReaderEvidence(record, error) {
  return {
    recordId: record.id,
    schema: record.schema,
    fieldNames: Object.keys(record).sort(),
    errorPath: error.path,
    timezoneState: owns(record, 'timezone')
      ? (record.timezone === null ? 'explicit-null' : typeof record.timezone)
      : 'absent',
  };
}

// Значения profile и адреса не выводим: для диагноза нужны контракт и путь ошибки.

Перечень key лучше сортировать только для стабильного evidence, а не использовать как порядок contract. RFC 8259 не обещает переносимую семантику порядка object members. Мы фиксируем, что timezone был или не был передан, но не делаем вывод из того, шёл ли он до settings. Для защищённых или персональных record hash, redaction и политика доступа добавляются в конкретной системе; fixture таких механизмов не изображает.

Первая классификация падения reader
СимптомEvidenceВероятная границаБезопасное первое действие
timezone имеет числоkey есть, type numbertype breakостановить новый writer для этого значения; не подставлять строку наугад
старый record не содержит timezonekey отсутствует, v1 samplepresence breakвернуть reader ветку absent, не писать null поверх record
legacy daily отвергнутv1 writer и старое допустимое значениеnarrowingотменить новый reader или расширить его договор; не менять legacy record массово
weekly прочитан, но эффект другойshape совпадает, meaning расходитсяsemantic breakостановить producer, который переиспользует значение; оформить отдельный field или migration plan

Различаем invalid value и неизвестное состояние

Типовой соблазн — сделать всё optional: если reader не понял поле, он молча берёт default. Это допустимо лишь когда default уже является частью договора и не скрывает факт. В нашем примере timezone: 3 — invalid value, поэтому reader v2 выбрасывает ошибку. Отсутствующая timezone — допустимый legacy state, поэтому reader возвращает { state: "absent" }. null — отдельная явная команда clear. Три ветки нужны, чтобы stop/rollback был основан на причине, а не на удобстве кода.

const oldRecord = { id: "profile-17", settings: { emailDigest: "weekly" } };
const clearRecord = { ...oldRecord, timezone: null };
const brokenRecord = { ...oldRecord, timezone: 3 };

readProfileByConsumerV2(oldRecord).timezone.state;   // absent
readProfileByConsumerV2(clearRecord).timezone.state; // explicit-null
readProfileByConsumerV2(brokenRecord);               // throws

Если actual reader не показывает эту разницу, сначала правят reader или договор, а не record. Перезапись absent в null создаёт видимость, что пользователь явно очистил значение. Превращение ошибочного числа в произвольную строку создаёт ещё один semantic guess. Обе правки усложняют расследование: следующие reader уже увидят синтетическое значение и не смогут отличить его от того, что producer действительно отправил.

Диагностическое дерево падения reader после записи profile.settings: собрать безопасное evidence, отличить type, absence/null, narrowing и semantic break; при рискованном изменении остановить producer, сохранить sample и вернуть изменение в compatibility matrix
Путь заканчивается действием только после классификации. Остановка producer не равна удалению record и не отменяет необходимость сохранить evidence.

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

Останавливать producer разумно, когда он продолжает создавать record, которые reader не может безопасно интерпретировать, или когда он меняет значение существующего key с новым смыслом. В type break это ограничивает появление новых неправильных record. В semantic break это останавливает смешение старого и нового meaning под одним словом. При ordinary additive поле, которое старый reader доказанно игнорирует, остановка может не понадобиться; это показывает matrix, а не интуиция.

Не нужно обещать универсальный ручной рубильник. В одних системах owner может отключить writer через release, в других — через конфигурацию, очередь или права. Статья не выбирает механизм. Её правило уже уже: если в compatibility test нет зелёной пары для активного reader, producer не должен увеличивать число спорных record. Сначала останавливают создание нового несовместимого значения, затем решают, можно ли безопасно восстановить reader или нужен отдельный перевод.

Выбор rollback-safe действия
УсловиеЧто можно откатитьЧто сохраняемЧего не делаем
новый optional key, v1 reader его игнорируетwriter можно остановить; reader оставляют tolerantv2 sample и matrixне удаляем key из уже записанных record без причины
reader ошибочно сузил emailDigestоткатываем reader contract или возвращаем legacy valuesample с daily и verdict тестане переписываем daily в другое значение массово
writer сменил semantic existing valueсначала останавливаем producerold/new meaning, record ids, owner decisionне называем простой code rollback восстановлением semantics
value неверного typeблокируем путь writer и чинить validatorошибочный sample и error pathне заменяем value fallback-строкой без правила

Rollback кода не всегда откатывает значение

Это самая опасная часть разборов. Если v2 writer добавил independent optional key, старый reader может продолжить читать core, а v2 reader — понимать уже появившийся key. Code rollback в такой ситуации обычно не должен стирать новые record. Но если writer использовал weekly в новом смысле, у уже записанного value нет метки, которая вернёт старую трактовку. Вернуть бинарник назад недостаточно: старый reader прочитает ту же строку и решит, что она означает по-старому. Здесь требуется отдельный, владеемый переход, а не скрытый cleanup.

Эта разница объясняет, почему evidence собирают раньше action. Сначала подтверждаем writer version, expected contract и samples. Затем выбираем rollback-safe маршрут: вернуть reader capability, остановить producer или подготовить новый field с явной семантикой. Важно не смешивать отмену кода с отменой данных. Реальное хранилище может иметь транзакции, snapshots, реплики или свои retention policy, но их нельзя приписывать нейтральной fixture.

Возвращаем дефект в compatibility test

После локализации случая он должен стать sample. Для type break добавляем record с timezone: 3 и ожидаем rejection. Для presence break сохраняем v1 record без key и ожидаем absent. Для narrowing сохраняем legacy daily и ожидаем, что proposed reader будет отклонён matrix. Для semantic break сохраняем semantic marker contract и ожидаем rejection, пока owner не введёт отдельное поле или не опишет контролируемый переход. Иначе следующий релиз снова увидит только «странный старый record».

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 уже есть тринадцать assertions. Они проверяют не реальную доставку, а условия учебного договора: v1/v2 reads, additive writer, absent versus explicit null, type rejection, order independence, narrowing, semantic and presence break. Если добавляется новая гарантия, её нельзя оставить в prose: нужен отдельный assertion. Это простая дисциплина для 2021 года — не утверждать, что reader «стал устойчивым», пока не видно, на каких input он обязан остановиться.

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

  1. Зафиксировать record id, writer label, reader label, field names, error path и state спорного key. Не выводить в общий лог весь profile.
  2. Проверить, это type break, absence/null, narrowing или semantic break. Одинаковый JavaScript type не отменяет semantic разрыв.
  3. Сверить активную пару с compatibility matrix. Если пары нет или она красная, остановить producer, который продолжает создавать спорный value.
  4. Для additive changes вернуть reader способность понимать old/new record. Для type или narrowing исправить contract и validator, а не подставлять default.
  5. Если старое значение получило новый смысл, считать code rollback недостаточным: сохранить evidence, назначить owner и проектировать отдельный transition.
  6. Добавить sample и ожидаемый verdict в fixture. После этого повторить только локальную matrix, затем выполнить integration checks выбранного storage отдельно.

Границы разбора и источники

RFC 8259 нужен здесь как граница JSON syntax и порядка object members. JSON Schema draft 2019-09 полезен для разговоров о structural validation, но не принимает за команду business decision о meaning поля. Apache Avro 1.10.2 показывает, что reader/writer resolution бывает частью конкретного формата; это не делает любое JSON-хранилище совместимым без matrix. Эти источники существовали к августу 2021 года и не используются для неподтверждённых claims о конкретном сервисе.

Пакет не выполняет rollback, не останавливает настоящий producer и не открывает storage. Нет реальных record, production-логов, SLA, метрик, схемы доступа, browser, CI или deployment. Следующий шаг — повторить этот маршрут на одном безопасно обезличенном sample выбранной системы, указать фактический owner и добавить форматно-зависимый integration test. До такой проверки безопаснее остановить изменение, чем превратить неясный contract в новые необратимые записи.

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

  • 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-объекта