{ "index": 217, "slug": "editorial-2021-12-field-architecture-review", "title": "Как проверить архитектурное решение, пока ошибка ещё обратима", "excerpt": "Пошаговая диагностика случая, когда запись сохранена, но публичное чтение её не показывает. Разбираем границы решения, канонический write, intent, повторную доставку и read contract.", "contentHtml": "
Проблема в учебном кейсе такова: пользователь сохраняет заметку, получает ответ 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