8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 229,
|
||
"slug": "editorial-2021-08-field-storage-contracts",
|
||
"title": "Reader упал после записи: как проверить контракт хранилища",
|
||
"excerpt": "Практический разбор сбоя после изменения записи: как отличить неверный тип, отсутствие поля, сужение старых значений и смену смысла, не уничтожить evidence и вернуть дефект в compatibility test.",
|
||
"contentHtml": "<p>Reader падает сразу после записи <code>profile.settings</code>. В логе появляется <code>unexpected value</code>. Producer уже выпустил новую версию записи, а старый consumer не знает, что с ней делать. Ошибка стоит дороже одного красного запроса: поспешный default, массовая перезапись или бездумный rollback могут стереть разницу между отсутствующим полем, явным <code>null</code> и новым смыслом старой строки. После этого нельзя точно сказать, что записал producer и что именно сломал reader.</p>\n<p>Главный тезис прост: контракт хранилища нужно проверять как пару writer/reader на конкретных образцах. Название <code>v2</code> ничего не гарантирует. Совместимость сохраняется только там, где известны обязательные поля, допустимые значения, смысл каждого значения и поведение при старой записи. Сначала сохраняют безопасное evidence. Потом классифицируют разрыв. И только после этого выбирают исправление.</p>\n<h2>Что считать контрактом</h2>\n<p>Запись — это не только набор ключей и типов. Контракт включает имя поля, его наличие, допустимые значения, семантику этих значений и реакцию reader на неизвестное поле. Для <code>profile.settings</code> можно зафиксировать небольшой договор: <code>id</code>, <code>displayName</code> и <code>settings</code> обязательны; <code>timezone</code> добавляется как optional; <code>emailDigest</code> принимает только <code>off</code>, <code>weekly</code> и <code>daily</code>. Старый reader может игнорировать новый optional key, но не обязан угадывать смысл существующего key.</p>\n<p>Совместимость имеет направление. Reader v1 читает запись writer v2 только если новый key не меняет обязательный core и старый reader безопасно игнорирует добавление. Reader v2 читает запись writer v1 только если он умеет обработать отсутствие нового optional key. Это две разные проверки. Успешное чтение в одну сторону не доказывает успех в другую.</p>\n<h2>Сначала собираем evidence</h2>\n<p>До изменения записи или конфигурации сохраните идентификатор записи, label версии writer, имя reader, путь ошибки, список ключей и состояние спорного поля. Значения профиля не нужны для первой классификации. Для персональных данных применяйте redaction и действующую политику доступа. Цель — восстановить форму записи и ожидаемую пару версий, а не скопировать содержимое в общий лог.</p>\n<pre><code>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 и адреса в общий лог.</code></pre>\n<p>Сортировка ключей нужна для стабильного вывода. Она не задаёт порядок чтения. JSON object не должен использовать порядок членов как межсистемный договор. Если перестановка ключей меняет результат, consumer зависит от свойства, которого формат не обещает. Это отдельная ошибка, даже если все типы выглядят правильно.</p>\n<div class=\"table-scroll\"><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td><code>timezone</code> содержит число</td><td>type break</td><td>Ключ есть, тип — <code>number</code></td><td>Остановить writer для такого значения и исправить проверку. Не подставлять строку наугад.</td></tr><tr><td>Старая запись без <code>timezone</code> отвергнута</td><td>presence break</td><td>Ключ отсутствует в образце v1</td><td>Вернуть ветку <code>absent</code>. Не записывать <code>null</code> поверх старых данных.</td></tr><tr><td>Старое <code>daily</code> больше не читается</td><td>narrowing</td><td>Legacy writer создавал это допустимое значение</td><td>Расширить reader или откатить его договор. Не переписывать записи массово.</td></tr><tr><td><code>weekly</code> читается с другим эффектом</td><td>semantic break</td><td>Форма и тип прежние, смысл изменился</td><td>Остановить producer и выделить новый key или управляемый переход.</td></tr></tbody></table></div>\n<h2>Разделяем отсутствие, null и ошибочное значение</h2>\n<p>Три состояния часто ошибочно сводят к одному default. Но у них разные причины и разные действия. Отсутствующий <code>timezone</code> означает, что старый writer не передал настройку. <code>timezone: null</code> может означать явную очистку, если это прописано в договоре. <code>timezone: 3</code> нарушает тип. Reader должен различать состояния, иначе rollback начнёт менять историю данных.</p>\n<pre><code>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</code></pre>\n<p>Учебный пример работает только с объектами в памяти. Он показывает требуемые состояния, но не проверяет конкретную базу, сериализатор, репликацию, права, retention или скорость обработки. В реальном сервисе те же образцы нужно пропустить через настоящий reader и validator. Локальный пример нельзя выдавать за гарантию всей системы.</p>\n<p>Если текущий reader не различает эти ветки, исправьте договор или reader до изменения записей. Перезапись absent в <code>null</code> придумывает факт явной очистки. Замена числа на строку придумывает значение. Оба действия уничтожают исходный сигнал и усложняют расследование следующего сбоя.</p>\n<figure><img src=\"/assets/editorial/2021/storage-contract-diagnosis-2021.svg\" alt=\"Дерево диагностики падения reader после записи: evidence, тип, отсутствие или null, сужение значений и смена смысла\" loading=\"lazy\" /><figcaption>К действию переходят после классификации разрыва. Остановка producer не означает удаление уже записанных данных.</figcaption></figure>\n<h2>Когда останавливать producer</h2>\n<p>Producer нужно остановить, если он продолжает создавать записи, которые активный reader не может безопасно интерпретировать. При type break это ограничивает число новых ошибочных записей. При semantic break это прекращает смешение старого и нового смысла под одним key. Механизм остановки зависит от системы: release, конфигурация, очередь или права. Статья не приписывает ей универсальный рубильник.</p>\n<p>Для additive change остановка может не понадобиться. Если compatibility matrix доказывает, что старый reader игнорирует новый optional key, writer может продолжить работу. Но это решение следует из проверки пары версий, а не из слова optional в схеме. Если зелёной пары нет, безопаснее остановить рост спорных записей до восстановления reader или подготовки перехода.</p>\n<div class=\"table-scroll\"><table><caption>Выбор действия без потери исходного значения</caption><thead><tr><th scope=\"col\">Условие</th><th scope=\"col\">Допустимое действие</th><th scope=\"col\">Сохраняем</th><th scope=\"col\">Не делаем</th></tr></thead><tbody><tr><td>Добавлен optional key</td><td>Оставить tolerant reader и при необходимости остановить writer</td><td>Старый и новый образцы</td><td>Не удалять key из всех записей без правила</td></tr><tr><td>Reader сузил <code>emailDigest</code></td><td>Вернуть прежнее допустимое множество или reader</td><td>Образец с <code>daily</code> и verdict</td><td>Не заменять <code>daily</code> другим значением массово</td></tr><tr><td>Существующее значение получило новый смысл</td><td>Остановить producer и спроектировать переход</td><td>Старый и новый смысл, owner решения</td><td>Не считать rollback кода rollback данных</td></tr><tr><td>Значение имеет неверный тип</td><td>Заблокировать такой путь writer и исправить validator</td><td>Ошибочный образец и error path</td><td>Не маскировать ошибку fallback-строкой</td></tr></tbody></table></div>\n<h2>Почему rollback кода не откатывает смысл</h2>\n<p>Если v2 добавила независимый optional key, возврат бинарника обычно не требует удаления новых записей. Старый reader может читать прежний core, а новый reader — core и дополнительный key. Но если producer начал использовать <code>weekly</code> в новом смысле, запись не содержит метки, которая восстановит старую трактовку. Возврат старого кода прочитает ту же строку и снова придаст ей старый смысл. Это не возвращает данные в прошлое.</p>\n<p>При semantic break нужны остановка producer, сохранение evidence и решение владельца данных. Иногда нужен новый key с новой семантикой. Иногда — явная миграция с версиями и обратимым этапом. Нельзя обещать snapshots, транзакции или реплики, если их свойства конкретной системы не проверены. Нельзя заменять это решение скрытым cleanup.</p>\n<h2>Возвращаем случай в compatibility test</h2>\n<p>Каждый найденный разрыв должен стать фиксированным образцом и проверкой ожидаемого результата. Для type break добавьте <code>timezone: 3</code> и ожидайте rejection. Для старой записи без поля ожидайте <code>absent</code>. Для narrowing сохраните legacy <code>daily</code> и запретите reader, который его отвергает. Для semantic break зафиксируйте старый смысл и ожидайте остановки до отдельного решения.</p>\n<pre><code>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.</code></pre>\n<p>Матрица должна содержать зелёные и красные пары. Одни успешные примеры показывают только то, что reader умеет принять известный input. Отрицательный пример доказывает, что опасная правка действительно остановится. Добавили правило — добавили sample и assertion. Изменили смысл — изменили контракт явно, а не только комментарий.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте record id, writer, reader, error path, список ключей и состояние спорного поля без вывода чувствительных значений.</li><li>Сравните активную пару writer/reader с ожидаемым контрактом и сохраните исходный образец до любой перезаписи.</li><li>Классифицируйте разрыв: type, absence/null, narrowing или semantic. Не называйте отсутствие поля ошибкой типа.</li><li>Проверьте, зависит ли результат от порядка ключей. Если зависит, уберите такую зависимость из reader.</li><li>Если producer продолжает создавать несовместимые записи, остановите его доступным для системы способом.</li><li>Выберите действие: восстановить reader, вернуть допустимое множество, исправить validator или выделить новый key для нового смысла.</li><li>Добавьте старый, новый и отрицательный образцы в compatibility matrix. Зафиксируйте ожидаемые accept и reject.</li><li>Повторите локальную проверку, затем отдельно выполните integration test настоящего формата и хранилища.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта схема не выбирает формат хранения и не делает JSON Schema универсальной политикой миграции. JSON Schema проверяет структурные утверждения, но не знает, какое бизнес-значение у строки и какие writer ещё живут в системе. Avro имеет собственные правила разрешения writer и reader schema, но они относятся к Avro, а не автоматически к произвольному JSON object. Реальное хранилище добавляет свои свойства: атомарность, репликацию, доступ, retention, лимиты и порядок доставки.</p>\n<p>Разбор готов, когда команда может повторить его на обезличенном образце и получить тот же verdict. Для каждой поддерживаемой пары есть базовый sample, additive-case и отрицательный case. Старый reader принимает только доказанно безопасную новую запись. Новый reader обрабатывает старую запись без обязательного поля. Invalid type, narrowing и semantic change дают ожидаемый отказ или отдельный контролируемый переход. В журнале остаются writer, reader, error path и owner решения. Production-эффект из учебных примеров не следует.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc8259.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format</a> — первичный стандарт JSON; он описывает object как набор пар имя/значение и не задаёт переносимый смысл порядка членов.</li><li><a href=\"https://json-schema.org/draft/2019-09/draft-handrews-json-schema-validation-02\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Validation draft 2019-09</a> — официальный документ о structural validation и типах, включая <code>null</code>; он не принимает решение о семантике прикладного поля.</li><li><a href=\"https://avro.apache.org/docs/1.10.2/spec.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.10.2 Specification</a> — официальная спецификация с правилами resolution writer и reader schema для конкретного формата.</li></ul>"
|
||
}
|