{ "index": 217, "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