diff --git a/editorial/agent-rewrites/217.json b/editorial/agent-rewrites/217.json index ca07a5d..e3dcba1 100644 --- a/editorial/agent-rewrites/217.json +++ b/editorial/agent-rewrites/217.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-12-field-architecture-review", "title": "Как проверить архитектурное решение, пока ошибка ещё обратима", "excerpt": "Пошаговая диагностика случая, когда запись сохранена, но публичное чтение её не показывает. Разбираем границы решения, канонический write, intent, повторную доставку и read contract.", - "contentHtml": "
Симптом выглядит просто: пользователь сохраняет заметку, получает успешный ответ, а затем не видит её в списке или по публичной ссылке. Команда сразу подозревает очередь, кеш или базу и повторяет операцию. Это опасный первый шаг. Повтор может создать вторую запись, второй intent или две проекции. После этого уже трудно установить, какая операция была исходной и на какой границе возникла ошибка. Цена ошибки — потеря версии, двойной побочный эффект и более дорогое расследование.
\nТезис статьи простой: архитектурное решение нужно проверять как причинную цепочку, а не как набор технологий. Сначала фиксируют наблюдаемый факт и идентификаторы. Затем отдельно проверяют решение, каноническую запись, намерение публикации, доставку и публичный контракт чтения. На каждой границе должно быть понятно, какое действие разрешено, а какое запрещено.
\nСохранение данных и их публичное чтение часто проходят через разные состояния. Каноническая запись хранит источник истины. Intent сообщает, что для неё нужно выполнить следующий эффект. Relay переносит intent в read model. Публичный запрос читает уже проекцию, поиск или кеш. Успех на одной границе не доказывает успех на другой.
\nДля диагностики нужна пара идентификаторов: noteId и revision. Один и тот же объект может иметь несколько версий. Если повторять действие только по заголовку или времени, система не отличит новую версию от повторной доставки старой. Для побочного эффекта нужен отдельный стабильный ключ. В учебном примере ниже ключ строится из идентификатора записи и версии:
const intentKey = `${note.id}:${note.revision}`;\n\nconst intent = {\n key: intentKey,\n noteId: note.id,\n revision: note.revision,\n effect: 'publish',\n};\n\n// Пример учебный: он показывает форму данных,\n// но не заменяет транзакцию, очередь или БД.\nТакой ключ не решает проблему сам по себе. Consumer должен хранить информацию о принятом ключе и сравнивать версию проекции с версией источника. Если ключ уже применён, повторный вызов должен вернуть тот же смысловой результат, а не создать новый эффект. Если версия отличается, система должна остановиться и передать случай на проверку конфликта.
\nФраза «публикация сломалась» уже содержит гипотезу. Факт короче: «после ответа 200 запрос GET /public/notes/42 не вернул revision 3 в 14:05:12». К факту добавляют способ чтения, область видимости, фильтры и момент проверки. Иначе команда сравнивает разные запросы и принимает различие контрактов за потерю данных.
\nМинимальная карточка наблюдения должна отвечать на пять вопросов: какой объект изменяли, какую версию ожидали, где прочитали результат, что получили и какое состояние уже подтверждено. Не нужно сразу собирать все логи. Нужны данные, которые отделяют canonical write от relay и relay от read contract.
\n| Симптом | Вероятная граница | Проверка | Действие |
|---|---|---|---|
| Нет noteId или revision | Наблюдение неполное | Сверить запрос, ответ и запись операции | Остановить повтор; восстановить идентификаторы |
| Есть intent, но нет канонической записи | Write path | Проверить commit source и порядок записи | Не запускать relay; исправить запись источника |
| Есть note, intent отсутствует | Граница write → publish | Проверить правило создания intent для точной revision | Не создавать копию; вернуть случай к границе записи |
| Intent pending | Relay path | Найти owner, ключ, статус job или receipt | Наблюдать обработку; source не менять |
| Intent применён, projection старая | Read projection | Сравнить key, revision и результат consumer | Разрешить controlled replay только выбранного ключа |
| Projection свежая, public read пуст | Read contract | Проверить scope, filter, права, кеш и query | Закончить ветку relay; исследовать запрос чтения |
Таблица не ставит диагноз по одному признаку. Она ограничивает следующий шаг. Пока не найдено подтверждение канонической записи, нельзя обсуждать повторную доставку. Пока projection совпадает с источником, нельзя объявлять relay причиной пустого списка. Такой порядок сохраняет возможность отката и не смешивает владельцев разных компонентов.
\nАрхитектурная запись нужна не для длинного описания системы. Она фиксирует контекст, выбранный вариант, обязательные требования, допущения, последствия и способ пересмотра. Если команда записала только «используем outbox», она не ответила на главные вопросы: где заканчивается транзакция, кто читает intent, какой ключ считается идемпотентным и что делать при конфликте версий.
\nDecision: public projection обновляется через intent и relay\nContext: canonical note уже записана, public read может быть отложенным\nMUST: повтор одного intent не создаёт второй effect\nMUST NOT: исправление удаляет source без отдельного доказательства\nAssumption: storage commit и intent имеют одну объявленную границу\nCheck: noteId + revision + intentKey видны в диагностике\nReview when: меняются storage, consumer или read contract\nЭто учебная форма записи. Она не объявляет распределённую транзакцию и не доказывает exactly-once delivery. Её задача — сделать проверяемыми условия и отрицательный путь. Если допущение не подтверждено, решение получает статус «нужно проверить», а не превращается в разрешение на изменение данных.
\nОсобенно полезно отделять обязательное требование от предпочтения. «Повтор не должен создавать двойной эффект» — требование. «Используем конкретный брокер» — вариант. Если выбранный брокер меняется, требование остаётся, а решение пересматривают по тем же проверкам. Так архитектура не привязывается к названию инструмента.
\nВ простом варианте запись note и intent выполняют в одной транзакционной границе. В более сложном варианте они могут попасть в разные хранилища или пройти через отдельный сервис. Тогда нужно честно назвать окно расхождения. Наличие двух успешных ответов от разных API не является доказательством общей атомарности.
\nЕсли note есть, а intent отсутствует, сначала проверяют границу записи: правило публикации, commit, обработчик после записи и разрешённый способ восстановления. Не создают новую note с другим id. Иначе исходная запись останется без intent, а новая начнёт отдельную причинную цепочку. Это удваивает проблему вместо восстановления состояния.
\nfunction publishCandidate(state, note) {\n const key = `${note.id}:${note.revision}`;\n\n if (!state.decisionAccepted) {\n return { status: 'blocked', reason: 'decision-missing' };\n }\n\n if (state.acceptedKeys.has(key)) {\n return { status: 'already-accepted', key };\n }\n\n state.intents.push({ key, noteId: note.id, revision: note.revision });\n return { status: 'intent-recorded', key };\n}\nКод иллюстративный. Он показывает две проверки: решение должно быть принято до изменения, а ключ должен быть стабильным. Массивы в памяти не защищают от падения процесса, гонки или частичной записи. В рабочей системе эти свойства нужно выразить средствами выбранного хранилища и проверить отдельным тестом.
\nСостояние pending означает только одно: существует разрешённое намерение, для которого ещё нет подтверждённого read effect. В зависимости от системы evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, нельзя называть случай pending по интуиции. Нужно вернуться к write boundary.
\nНельзя очищать source, чтобы «запустить процесс заново». Нельзя менять revision, чтобы скрыть конфликт. Нельзя отправлять широкий replay без ограничения ключом. Пока owner relay не подтвердил результат, безопасное действие — сохранить исходное состояние и собрать недостающий evidence.
\nПовторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат предыдущей попытки. Relay должен проверять ключ до выполнения эффекта. Если ключ уже принят, consumer не создаёт вторую проекцию. Если ключ неизвестен, он применяет только заявленную версию и записывает результат.
\nReplay не исправляет неверный контракт чтения. Если проекция уже содержит revision 3, но поиск её не возвращает, повтор relay не добавляет доказательств. Нужно проверить фильтр, tenant, права, кеш, формат идентификатора и конкретный query. Публичный список и прямое чтение по id могут иметь разные правила. Это отдельная причина, а не продолжение relay.
\nОтрицательный путь важнее happy path. Если revision проекции новее источника, обработчик не должен молча откатывать её старой доставкой. Если один ключ связан с другим payload, обработчик не должен считать запрос повтором. Он должен вернуть конфликт и сохранить обе версии для разбора. Иначе идемпотентность превращается в тихое подавление ошибки.
\nПорядок важен потому, что каждая операция меняет наблюдаемое состояние. Удаление или повтор записи до фиксации evidence уничтожает исходный контекст. Диагностика должна вести к меньшему числу возможных причин, а не создавать новые варианты.
\nЭта схема не делает eventual consistency мгновенной. Она не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если commit и intent находятся в разных хранилищах, нужно отдельно описать окно расхождения и способ сверки.
\nПример с Map и массивом применим только для объяснения формы состояний. Он не учитывает несколько процессов, конкурирующие записи, рестарт, сетевой timeout и повтор после неизвестного результата. В реальной системе идентификатор операции должен сохраняться достаточно долго, чтобы обработчик отличал поздний повтор от нового намерения. Политику хранения ключей выбирают по сроку возможного повтора и риску побочного эффекта.
\nМетод не разрешает менять требования задним числом. Если выяснилось, что задержка недопустима, это новое требование к решению. Если public read должен быть строго синхронным, outbox с отложенной проекцией может не подходить. В таком случае фиксируют несовпадение и пересматривают вариант, а не маскируют его дополнительными retry.
\nРазбор готов, когда другой инженер может повторить его без устного контекста. В записи есть исходный симптом, идентификаторы, граница причины, evidence проверки, разрешённое действие и ограничение примера. Для конкретного случая должны быть видны noteId, revision, intentKey и результат public read. Для повторной доставки отдельно указаны owner, условие идемпотентности и ожидаемый результат повторного запроса.
\nПроверяемый критерий можно сформулировать так: один и тот же intentKey при двух одинаковых доставках даёт не более одного эффекта, а доставка старой revision не затирает более новую проекцию. Для пустого public read критерий другой: после подтверждённой свежей проекции найдено объяснение в scope, filter, правах, кеше или query. Если ни один критерий нельзя проверить по сохранённому evidence, разбор ещё не закончен.
\nПроблема в учебном кейсе такова: пользователь сохраняет заметку, получает ответ 200, а затем не видит её в публичном списке. Прямой запрос по идентификатору тоже возвращает пустой результат. Команда подозревает очередь, кеш или базу и хочет повторить запись. Это опасный первый шаг: повтор может создать вторую заметку, второй intent или две проекции. После этого уже трудно установить исходную операцию и границу сбоя.
\nПроверка архитектурного решения начинается с наблюдаемого факта и продолжается по причинной цепочке. Нужно отдельно проверить решение, каноническую запись, намерение публикации, доставку и контракт публичного чтения. На каждой границе заранее фиксируют разрешённое действие, запрещённое действие и evidence — сохранённое подтверждение результата.
\nСценарий воспроизводится на одной тестовой заметке. Сначала клиент отправляет команду сохранения и получает пару noteId и revision. Затем тот же клиент выполняет публичный GET. Пустой ответ — факт наблюдения, а не доказательство того, что сломалась очередь.
const writeResponse = { status: 200, noteId: '42', revision: 3, intentKey: '42:3:publish' };\nconst publicResponse = { status: 200, note: null };\n\n// Зафиксирован факт: revision 3 сохранена,\n// но GET /public/notes/42 её не вернул.\nВ карточке проверки сохраняют запросы, время, область видимости, фильтры, права доступа и полный ответ чтения. Формулировка «публикация сломалась» уже содержит гипотезу. Формулировка «после ответа 200 GET /public/notes/42 не вернул revision 3» позволяет проверить несколько причин, не изменяя данные.
\nЕсли команда не записала идентификаторы и границы запроса, повтор не добавит знания. Он только изменит состояние, которое нужно расследовать. Поэтому первое решение в кейсе — остановить повтор записи и восстановить evidence исходной операции.
\nКаноническая запись — источник истины для заметки. Intent — сохранённое намерение выполнить следующий эффект, например публикацию. Relay — обработчик, который переносит intent дальше. Проекция — read model, подготовленная для чтения. Публичный запрос может обращаться к проекции, поисковому индексу или кешу, а не к канонической записи.
\nЭти состояния могут обновляться на разных границах. Успешная транзакция записи не доказывает, что intent создан. Созданный intent не доказывает, что relay обработал его. Свежая проекция не доказывает, что конкретный фильтр публичного запроса её вернёт. Диагностика должна сохранять эти различия.
\nДля заметки из кейса минимальный набор идентификаторов выглядит так: noteId = 42, revision = 3 и ключ намерения intentKey = 42:3:publish. Ключ включает эффект, потому что одна revision может участвовать не только в публикации. Производный ключ допустим лишь при явном доменном правиле: для одной заметки и revision разрешён один publish. Если одинаковая revision может публиковаться дважды с разным смыслом, ключ нужно выдавать на границе операции, а не вычислять из данных.
| Наблюдение | Вероятная граница | Проверка | До проверки нельзя |
|---|---|---|---|
| Нет noteId или revision | Неполное наблюдение | Сверить запрос, ответ и журнал операции | Повторять запись |
| Есть intent, нет канонической записи | Write path | Проверить commit и порядок записи | Запускать relay |
| Есть note, нет intent | Write → publish | Проверить правило создания intent для точной revision | Создавать новую note |
| Intent pending | Relay path | Найти owner, ключ и статус обработки | Менять source |
| Intent применён, projection старая | Read model | Сравнить key, revision и результат consumer | Делать широкий replay |
| Projection свежая, public read пуст | Read contract | Проверить scope, filter, права, кеш и query | Повторять relay |
Таблица ограничивает следующий шаг, но не ставит диагноз по одному признаку. Пока каноническая запись не подтверждена, очередь не является доказанной причиной. Если projection уже содержит revision 3, повтор relay не объяснит пустой публичный список. В этом случае владелец расследования меняется: нужно проверять контракт чтения.
\nЗапись решения должна отвечать на пять вопросов: какой контекст принят, какой вариант выбран, какие требования обязательны, какие допущения сделаны и когда вариант пересматривают. Фраза «используем outbox» слишком коротка. Outbox — это способ сохранить намерение рядом с изменением, но сама надпись не показывает границу транзакции, владельца обработки или политику повтора.
\nDecision: public projection обновляется через intent и relay\nContext: canonical note уже записана, public read может быть отложенным\nMUST: повтор одного intentKey не создаёт второй effect\nMUST NOT: исправление удаляет source без отдельного evidence\nAssumption: commit note и запись intent имеют объявленную границу\nCheck: noteId, revision и intentKey видны в диагностике\nReview when: меняются storage, consumer или read contract\nЭто форма фиксации условий, а не обещание распределённой транзакции или exactly-once delivery. Если note и intent находятся в разных хранилищах, между ними существует окно расхождения. Его нужно назвать, измерить или ограничить, а для восстановления определить отдельную процедуру сверки.
\nНормативные слова в примере относятся к самому решению. RFC 8174 объясняет, когда написанные заглавными буквами MUST, SHOULD и MAY получают нормативный смысл в техническом документе. В чужой системе эти слова ничего не гарантируют, пока команда не закрепила их в контракте, тесте или проверяемом правиле хранения.
\nЕсли заметка уже записана, а intent отсутствует, нужно вернуться к границе write → publish. Проверяют commit, обработчик после записи, правило публикации и разрешённый способ восстановления. Новую заметку с другим id не создают: исходная останется без intent, а новая начнёт независимую причинную цепочку.
\nЕсли note и intent пишутся в одной транзакции, команда должна подтвердить это свойствами выбранного хранилища, а не двумя успешными ответами разных API. Если записи разделены, успешность одной операции не является доказательством атомарности другой. Именно это допущение чаще всего скрывает источник необратимой ошибки.
\nconst state = {\n decisionAccepted: true,\n recordedKeys: new Set(),\n intents: [],\n};\n\nfunction recordPublishIntent(state, note) {\n const key = note.id + ':' + note.revision + ':publish';\n\n if (!state.decisionAccepted) {\n return { status: 'blocked', reason: 'decision-missing' };\n }\n\n if (state.recordedKeys.has(key)) {\n return { status: 'already-recorded', key };\n }\n\n state.intents.push({ key, noteId: note.id, revision: note.revision });\n state.recordedKeys.add(key);\n return { status: 'intent-recorded', key };\n}\nПри последовательном вызове функция возвращает intent-recorded один раз, а второй вызов — already-recorded. Это делает отрицательный путь видимым. Но Set живёт только в памяти процесса: рестарт, два процесса и гонка между проверкой и записью нарушат гарантию. В рабочей системе уникальность ключа и запись intent должны быть защищены транзакцией или уникальным ограничением хранилища.
Pending означает, что разрешённое намерение существует, но подтверждение read effect ещё не найдено. Evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, состояние нельзя назвать pending по интуиции: сначала проверяют, была ли запись intent.
\nПока relay не подтвердил результат, безопасное действие — сохранить source и собрать недостающие данные. Нельзя очищать source, менять revision или отправлять широкий replay «на всякий случай». Такие операции уничтожают исходный контекст и могут повторить побочный эффект для нескольких записей.
\nПовторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат первой попытки. Relay проверяет ключ до эффекта. Уже записанный ключ означает повтор того же намерения, а не разрешение создать вторую проекцию.
AWS Builders’ Library описывает ту же границу для идемпотентных API: повтор должен иметь устойчивый идентификатор, а сервер должен отличать повторное обращение от нового намерения. Для части операций AWS связывает запись идентификатора и изменение ресурса атомарной операцией. В нашем кейсе это не готовая реализация, а критерий, с которым сравнивают проектное решение.
\nНельзя считать повтором запрос только потому, что его параметры совпали. Владелец мог дважды намеренно сохранить одинаковый текст. Поэтому ключ должен выражать семантику операции. Если сервер видит тот же ключ с другим payload, он возвращает конфликт, а не молча принимает вторую версию.
\nСтарая доставка также не должна затирать новую проекцию. Если в projection уже стоит revision 4, событие для revision 3 останавливают и сохраняют конфликт. Идемпотентность — это не подавление ошибок, а повторяемое решение для одного ключа при сохранении несовместимых состояний.
\nПорядок защищает расследование от самоподтверждающейся ошибки. Каждое изменение состояния может скрыть первопричину, поэтому сначала сохраняют доказательства, затем выбирают узкую операцию. После действия повторяют тот же read contract и сравнивают revision, а не только HTTP-статус.
\nСхема не делает отложенную согласованность мгновенной и не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если public read обязан быть строго синхронным, отложенная проекция через outbox может не соответствовать требованию. Вариант пересматривают, а не маскируют несовпадение дополнительными retry.
\nУчебный код не учитывает несколько процессов, конкурентные записи, рестарт, сетевой timeout и повтор после неизвестного результата. Политику хранения ключей выбирают по сроку возможного повтора и риску эффекта. Для реального проекта отдельно проверяют уникальность ключа, TTL, конфликт payload и поведение после частичного commit.
\nРазбор готов, когда другой инженер может повторить его без устного контекста. В записи видны исходный симптом, noteId, revision, intentKey, граница причины, evidence, владелец следующего шага и разрешённое действие. Критерий для доставки: две одинаковые доставки одного ключа дают не более одного эффекта, а старая revision не затирает новую projection. Критерий для пустого чтения: после подтверждения свежей projection найдено объяснение в scope, filter, правах, кеше или query.
\n