Симптом выглядит коротко: 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 таких механизмов не изображает.
| Симптом | Evidence | Вероятная граница | Безопасное первое действие |
|---|---|---|---|
timezone имеет число | key есть, type number | type break | остановить новый writer для этого значения; не подставлять строку наугад |
старый record не содержит timezone | key отсутствует, v1 sample | presence 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 действительно отправил.
Когда producer нужно остановить
Останавливать producer разумно, когда он продолжает создавать record, которые reader не может безопасно интерпретировать, или когда он меняет значение существующего key с новым смыслом. В type break это ограничивает появление новых неправильных record. В semantic break это останавливает смешение старого и нового meaning под одним словом. При ordinary additive поле, которое старый reader доказанно игнорирует, остановка может не понадобиться; это показывает matrix, а не интуиция.
Не нужно обещать универсальный ручной рубильник. В одних системах owner может отключить writer через release, в других — через конфигурацию, очередь или права. Статья не выбирает механизм. Её правило уже уже: если в compatibility test нет зелёной пары для активного reader, producer не должен увеличивать число спорных record. Сначала останавливают создание нового несовместимого значения, затем решают, можно ли безопасно восстановить reader или нужен отдельный перевод.
| Условие | Что можно откатить | Что сохраняем | Чего не делаем |
|---|---|---|---|
| новый optional key, v1 reader его игнорирует | writer можно остановить; reader оставляют tolerant | v2 sample и matrix | не удаляем key из уже записанных record без причины |
reader ошибочно сузил emailDigest | откатываем reader contract или возвращаем legacy value | sample с daily и verdict теста | не переписываем daily в другое значение массово |
| writer сменил semantic existing value | сначала останавливаем producer | old/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 он обязан остановиться.
Маршрут: симптом → причина → проверка → действие
- Зафиксировать record id, writer label, reader label, field names, error path и state спорного key. Не выводить в общий лог весь profile.
- Проверить, это type break, absence/null, narrowing или semantic break. Одинаковый JavaScript type не отменяет semantic разрыв.
- Сверить активную пару с compatibility matrix. Если пары нет или она красная, остановить producer, который продолжает создавать спорный value.
- Для additive changes вернуть reader способность понимать old/new record. Для type или narrowing исправить contract и validator, а не подставлять default.
- Если старое значение получило новый смысл, считать code rollback недостаточным: сохранить evidence, назначить owner и проектировать отдельный transition.
- Добавить 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-объекта