diff --git a/editorial/agent-rewrites/229.json b/editorial/agent-rewrites/229.json index cf9c034..9229477 100644 --- a/editorial/agent-rewrites/229.json +++ b/editorial/agent-rewrites/229.json @@ -3,5 +3,5 @@ "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.
Главный тезис прост: контракт хранилища нужно проверять как пару writer/reader на конкретных образцах. Название v2 ничего не гарантирует. Совместимость сохраняется только там, где известны обязательные поля, допустимые значения, смысл каждого значения и поведение при старой записи. Сначала сохраняют безопасное evidence. Потом классифицируют разрыв. И только после этого выбирают исправление.
Запись — это не только набор ключей и типов. Контракт включает имя поля, его наличие, допустимые значения, семантику этих значений и реакцию reader на неизвестное поле. Для profile.settings можно зафиксировать небольшой договор: id, displayName и settings обязательны; timezone добавляется как optional; emailDigest принимает только off, weekly и daily. Старый reader может игнорировать новый optional key, но не обязан угадывать смысл существующего key.
Совместимость имеет направление. Reader v1 читает запись writer v2 только если новый key не меняет обязательный core и старый reader безопасно игнорирует добавление. Reader v2 читает запись writer v1 только если он умеет обработать отсутствие нового optional key. Это две разные проверки. Успешное чтение в одну сторону не доказывает успех в другую.
\nДо изменения записи или конфигурации сохраните идентификатор записи, label версии writer, имя reader, путь ошибки, список ключей и состояние спорного поля. Значения профиля не нужны для первой классификации. Для персональных данных применяйте redaction и действующую политику доступа. Цель — восстановить форму записи и ожидаемую пару версий, а не скопировать содержимое в общий лог.
\nconst 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 больше не читается | narrowing | Legacy writer создавал это допустимое значение | Расширить reader или откатить его договор. Не переписывать записи массово. |
weekly читается с другим эффектом | semantic break | Форма и тип прежние, смысл изменился | Остановить producer и выделить новый key или управляемый переход. |
Три состояния часто ошибочно сводят к одному default. Но у них разные причины и разные действия. Отсутствующий timezone означает, что старый writer не передал настройку. timezone: null может означать явную очистку, если это прописано в договоре. timezone: 3 нарушает тип. Reader должен различать состояния, иначе rollback начнёт менять историю данных.
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 придумывает факт явной очистки. Замена числа на строку придумывает значение. Оба действия уничтожают исходный сигнал и усложняют расследование следующего сбоя.
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-строкой |
Если v2 добавила независимый optional key, возврат бинарника обычно не требует удаления новых записей. Старый reader может читать прежний core, а новый reader — core и дополнительный key. Но если producer начал использовать weekly в новом смысле, запись не содержит метки, которая восстановит старую трактовку. Возврат старого кода прочитает ту же строку и снова придаст ей старый смысл. Это не возвращает данные в прошлое.
При semantic break нужны остановка producer, сохранение evidence и решение владельца данных. Иногда нужен новый key с новой семантикой. Иногда — явная миграция с версиями и обратимым этапом. Нельзя обещать snapshots, транзакции или реплики, если их свойства конкретной системы не проверены. Нельзя заменять это решение скрытым cleanup.
\nКаждый найденный разрыв должен стать фиксированным образцом и проверкой ожидаемого результата. Для type break добавьте timezone: 3 и ожидайте rejection. Для старой записи без поля ожидайте absent. Для narrowing сохраните legacy daily и запретите reader, который его отвергает. Для semantic break зафиксируйте старый смысл и ожидайте остановки до отдельного решения.
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Эта схема не выбирает формат хранения и не делает 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-эффект из учебных примеров не следует.
\nnull; он не принимает решение о семантике прикладного поля.Компонент чтения (reader) падает сразу после записи profile.settings. В логе появляется unexpected value. Компонент записи (writer) уже выпустил новую версию записи, а старый reader не знает, что с ней делать. Ошибка стоит дороже одного красного запроса: поспешный default, массовая перезапись или бездумный rollback могут стереть разницу между отсутствующим полем, явным null и новым смыслом старой строки. После этого нельзя точно сказать, что записал producer и что именно сломал reader.
Главный тезис прост: контракт хранилища нужно проверять как пару writer/reader на конкретных образцах. Название v2 ничего не гарантирует. Совместимость сохраняется только там, где известны обязательные поля, допустимые значения, смысл каждого значения и поведение при старой записи. Сначала сохраняют безопасное evidence. Потом классифицируют разрыв. И только после этого выбирают исправление.
Запись — это не только набор ключей и типов. Контракт включает имя поля, его наличие, допустимые значения, семантику этих значений и реакцию reader на неизвестное поле. Для версии v1 у profile.settings можно зафиксировать небольшой договор: id, displayName и settings обязательны; timezone пока отсутствует; если задано emailDigest, оно принимает только off, weekly и daily. Reader v2 может добавить optional key, но не должен самовольно сужать это множество или угадывать смысл существующего key.
Совместимость имеет направление. Reader v1 читает запись writer v2 только если новый key не меняет обязательный core и старый reader безопасно игнорирует добавление. Reader v2 читает запись writer v1 только если он умеет обработать отсутствие нового optional key. Это две разные проверки. Успешное чтение в одну сторону не доказывает успех в другую.
\nДо изменения записи или конфигурации сохраните идентификатор записи, label версии writer, имя reader, путь ошибки, список ключей и состояние спорного поля. Значения профиля не нужны для первой классификации. Для персональных данных применяйте redaction и действующую политику доступа. Цель — восстановить форму записи и ожидаемую пару версий, а не скопировать содержимое в общий лог.
\nconst 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 больше не читается | narrowing | Legacy writer создавал это допустимое значение | Расширить reader или откатить его договор. Не переписывать записи массово. |
weekly читается с другим эффектом | semantic break | Форма и тип прежние, смысл изменился | Остановить producer и выделить новый key или управляемый переход. |
Три состояния часто ошибочно сводят к одному default. Но у них разные причины и разные действия. Отсутствующий timezone означает, что старый writer не передал настройку. timezone: null может означать явную очистку, если это прописано в договоре. timezone: 3 нарушает тип. Reader должен различать состояния, иначе rollback начнёт менять историю данных.
function readProfileV2(record) {\n if (typeof record.id !== 'string' || !record.settings || typeof record.settings !== 'object') {\n throw new TypeError('id and settings are required');\n }\n\n const digest = record.settings.emailDigest;\n if (digest !== undefined && !new Set(['off', 'weekly']).has(digest)) {\n throw new RangeError('emailDigest is not supported by reader v2');\n }\n\n if (!owns(record, 'timezone')) return { ...record, timezone: { state: 'absent' } };\n if (record.timezone === null) return { ...record, timezone: { state: 'explicit-null', value: null } };\n if (typeof record.timezone !== 'string') {\n throw new TypeError('timezone must be a string or null');\n }\n return { ...record, timezone: { state: 'value', value: record.timezone } };\n}\n\nconst oldRecord = {\n id: 'profile-17',\n schema: 'v1',\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'\ntry {\n readProfileV2(brokenRecord);\n} catch (error) {\n // Ожидаемый rejection для неверного типа timezone.\n console.log(error.message);\n}\nУчебный пример работает только с объектами в памяти. Он показывает требуемые состояния, но не проверяет конкретную базу, сериализатор, репликацию, права, retention или скорость обработки. В реальном сервисе те же образцы нужно пропустить через настоящий reader и validator. Локальный пример нельзя выдавать за гарантию всей системы.
\nЕсли текущий reader не различает эти ветки, исправьте договор или reader до изменения записей. Перезапись absent в null придумывает факт явной очистки. Замена числа на строку придумывает значение. Оба действия уничтожают исходный сигнал и усложняют расследование следующего сбоя.
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 v2 сузил emailDigest | Вернуть прежнее допустимое множество в reader v2 или восстановить reader | Образец с daily и verdict | Не заменять daily другим значением массово |
| Существующее значение получило новый смысл | Остановить producer и спроектировать переход | Старый и новый смысл, owner решения | Не считать rollback кода rollback данных |
| Значение имеет неверный тип | Заблокировать такой путь writer и исправить validator | Ошибочный образец и error path | Не маскировать ошибку fallback-строкой |
Если v2 добавила независимый optional key, возврат бинарника обычно не требует удаления новых записей. Старый reader может читать прежний core, а новый reader — core и дополнительный key. Но если producer начал использовать weekly в новом смысле, запись не содержит метки, которая восстановит старую трактовку. Возврат старого кода прочитает ту же строку и снова придаст ей старый смысл. Это не возвращает данные в прошлое.
При semantic break нужны остановка producer, сохранение evidence и решение владельца данных. Иногда нужен новый key с новой семантикой. Иногда — явная миграция с версиями и обратимым этапом. Нельзя обещать snapshots, транзакции или реплики, если их свойства конкретной системы не проверены. Нельзя заменять это решение скрытым cleanup.
\nКаждый найденный разрыв должен стать фиксированным образцом и проверкой ожидаемого результата. Для type break добавьте timezone: 3 и ожидайте rejection. Для старой записи без поля ожидайте absent. Для narrowing сохраните legacy daily и отдельно проверьте reader v2, который сузил множество до off/weekly. Для semantic break зафиксируйте старый смысл и ожидайте остановки до отдельного решения.
const narrowedRecord = {\n ...oldRecord,\n settings: { ...oldRecord.settings, emailDigest: 'daily' },\n};\n\nfunction tryRead(record) {\n try {\n readProfileV2(record);\n return 'accept';\n } catch {\n return 'reject';\n }\n}\n\nconst cases = [\n ['v1 record without timezone', oldRecord, 'accept'],\n ['explicit clear', clearRecord, 'accept'],\n ['wrong timezone type', brokenRecord, 'reject'],\n ['legacy daily under narrowed reader', narrowedRecord, '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Эта схема не выбирает формат хранения и не делает 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.
\nnull; он не принимает решение о семантике прикладного поля.