From 3843a294ca1fe41d0b00c50b7b4a4dd2dabff4fb Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 20:23:36 +0300 Subject: [PATCH] editorial: refine article 217 architecture review --- editorial/agent-rewrites/217.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

Механизм: одна запись, несколько границ

\n

Сохранение данных и их публичное чтение часто проходят через разные состояния. Каноническая запись хранит источник истины. Intent сообщает, что для неё нужно выполнить следующий эффект. Relay переносит intent в read model. Публичный запрос читает уже проекцию, поиск или кеш. Успех на одной границе не доказывает успех на другой.

\n

Для диагностики нужна пара идентификаторов: noteId и revision. Один и тот же объект может иметь несколько версий. Если повторять действие только по заголовку или времени, система не отличит новую версию от повторной доставки старой. Для побочного эффекта нужен отдельный стабильный ключ. В учебном примере ниже ключ строится из идентификатора записи и версии:

\n
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

Сначала отделите факт от гипотезы

\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 pendingRelay pathНайти owner, ключ, статус job или receiptНаблюдать обработку; source не менять
Intent применён, projection стараяRead projectionСравнить key, revision и результат consumerРазрешить controlled replay только выбранного ключа
Projection свежая, public read пустRead contractПроверить scope, filter, права, кеш и queryЗакончить ветку relay; исследовать запрос чтения
\n

Таблица не ставит диагноз по одному признаку. Она ограничивает следующий шаг. Пока не найдено подтверждение канонической записи, нельзя обсуждать повторную доставку. Пока projection совпадает с источником, нельзя объявлять relay причиной пустого списка. Такой порядок сохраняет возможность отката и не смешивает владельцев разных компонентов.

\n

Решение должно фиксировать границы

\n

Архитектурная запись нужна не для длинного описания системы. Она фиксирует контекст, выбранный вариант, обязательные требования, допущения, последствия и способ пересмотра. Если команда записала только «используем outbox», она не ответила на главные вопросы: где заканчивается транзакция, кто читает intent, какой ключ считается идемпотентным и что делать при конфликте версий.

\n
Decision: 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

Канонический write и intent нельзя считать одним эффектом

\n

В простом варианте запись note и intent выполняют в одной транзакционной границе. В более сложном варианте они могут попасть в разные хранилища или пройти через отдельный сервис. Тогда нужно честно назвать окно расхождения. Наличие двух успешных ответов от разных API не является доказательством общей атомарности.

\n

Если note есть, а intent отсутствует, сначала проверяют границу записи: правило публикации, commit, обработчик после записи и разрешённый способ восстановления. Не создают новую note с другим id. Иначе исходная запись останется без intent, а новая начнёт отдельную причинную цепочку. Это удваивает проблему вместо восстановления состояния.

\n
function 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 не означает потерю

\n

Состояние pending означает только одно: существует разрешённое намерение, для которого ещё нет подтверждённого read effect. В зависимости от системы evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, нельзя называть случай pending по интуиции. Нужно вернуться к write boundary.

\n

Нельзя очищать source, чтобы «запустить процесс заново». Нельзя менять revision, чтобы скрыть конфликт. Нельзя отправлять широкий replay без ограничения ключом. Пока owner relay не подтвердил результат, безопасное действие — сохранить исходное состояние и собрать недостающий evidence.

\n
\"Дерево
Граница причины меняется только после подтверждения предыдущего состояния.
\n

Когда допустим controlled replay

\n

Повторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат предыдущей попытки. Relay должен проверять ключ до выполнения эффекта. Если ключ уже принят, consumer не создаёт вторую проекцию. Если ключ неизвестен, он применяет только заявленную версию и записывает результат.

\n

Replay не исправляет неверный контракт чтения. Если проекция уже содержит revision 3, но поиск её не возвращает, повтор relay не добавляет доказательств. Нужно проверить фильтр, tenant, права, кеш, формат идентификатора и конкретный query. Публичный список и прямое чтение по id могут иметь разные правила. Это отдельная причина, а не продолжение relay.

\n

Отрицательный путь важнее happy path. Если revision проекции новее источника, обработчик не должен молча откатывать её старой доставкой. Если один ключ связан с другим payload, обработчик не должен считать запрос повтором. Он должен вернуть конфликт и сохранить обе версии для разбора. Иначе идемпотентность превращается в тихое подавление ошибки.

\n

Порядок действий

\n
  1. Зафиксировать точный симптом: запрос, время, noteId, ожидаемую revision, scope и полученный ответ.
  2. Проверить решение: есть ли контекст, требования, допущения, выбранный вариант и явный отрицательный путь.
  3. Проверить каноническую запись по noteId и revision. Не создавать замену до завершения этой проверки.
  4. Проверить intent для точного ключа noteId:revision. Если ключа нет, исследовать write boundary, а не очередь.
  5. Если intent pending, найти owner relay и разрешённое evidence обработки. Source не изменять.
  6. Если intent применён, сравнить revision проекции с revision источника. Replay ограничить одним ключом и одним известным consumer.
  7. Если проекция свежая, проверить read contract: scope, filter, права, кеш, формат и query.
  8. Записать результат проверки и только затем выполнить обратимое действие на найденной границе.
\n

Порядок важен потому, что каждая операция меняет наблюдаемое состояние. Удаление или повтор записи до фиксации evidence уничтожает исходный контекст. Диагностика должна вести к меньшему числу возможных причин, а не создавать новые варианты.

\n

Ограничения метода

\n

Эта схема не делает eventual consistency мгновенной. Она не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если commit и intent находятся в разных хранилищах, нужно отдельно описать окно расхождения и способ сверки.

\n

Пример с Map и массивом применим только для объяснения формы состояний. Он не учитывает несколько процессов, конкурирующие записи, рестарт, сетевой timeout и повтор после неизвестного результата. В реальной системе идентификатор операции должен сохраняться достаточно долго, чтобы обработчик отличал поздний повтор от нового намерения. Политику хранения ключей выбирают по сроку возможного повтора и риску побочного эффекта.

\n

Метод не разрешает менять требования задним числом. Если выяснилось, что задержка недопустима, это новое требование к решению. Если public read должен быть строго синхронным, outbox с отложенной проекцией может не подходить. В таком случае фиксируют несовпадение и пересматривают вариант, а не маскируют его дополнительными retry.

\n

Критерий готовности

\n

Разбор готов, когда другой инженер может повторить его без устного контекста. В записи есть исходный симптом, идентификаторы, граница причины, evidence проверки, разрешённое действие и ограничение примера. Для конкретного случая должны быть видны noteId, revision, intentKey и результат public read. Для повторной доставки отдельно указаны owner, условие идемпотентности и ожидаемый результат повторного запроса.

\n

Проверяемый критерий можно сформулировать так: один и тот же intentKey при двух одинаковых доставках даёт не более одного эффекта, а доставка старой revision не затирает более новую проекцию. Для пустого public read критерий другой: после подтверждённой свежей проекции найдено объяснение в scope, filter, правах, кеше или query. Если ни один критерий нельзя проверить по сохранённому evidence, разбор ещё не закончен.

\n

Проверяемые источники

\n" + "contentHtml": "

Проблема в учебном кейсе такова: пользователь сохраняет заметку, получает ответ 200, а затем не видит её в публичном списке. Прямой запрос по идентификатору тоже возвращает пустой результат. Команда подозревает очередь, кеш или базу и хочет повторить запись. Это опасный первый шаг: повтор может создать вторую заметку, второй intent или две проекции. После этого уже трудно установить исходную операцию и границу сбоя.

\n

Проверка архитектурного решения начинается с наблюдаемого факта и продолжается по причинной цепочке. Нужно отдельно проверить решение, каноническую запись, намерение публикации, доставку и контракт публичного чтения. На каждой границе заранее фиксируют разрешённое действие, запрещённое действие и evidence — сохранённое подтверждение результата.

\n

Сценарий: запись есть, чтения нет

\n

Сценарий воспроизводится на одной тестовой заметке. Сначала клиент отправляет команду сохранения и получает пару noteId и revision. Затем тот же клиент выполняет публичный GET. Пустой ответ — факт наблюдения, а не доказательство того, что сломалась очередь.

\n
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

Механизм: одна команда, несколько состояний

\n

Каноническая запись — источник истины для заметки. Intent — сохранённое намерение выполнить следующий эффект, например публикацию. Relay — обработчик, который переносит intent дальше. Проекция — read model, подготовленная для чтения. Публичный запрос может обращаться к проекции, поисковому индексу или кешу, а не к канонической записи.

\n

Эти состояния могут обновляться на разных границах. Успешная транзакция записи не доказывает, что intent создан. Созданный intent не доказывает, что relay обработал его. Свежая проекция не доказывает, что конкретный фильтр публичного запроса её вернёт. Диагностика должна сохранять эти различия.

\n

Для заметки из кейса минимальный набор идентификаторов выглядит так: noteId = 42, revision = 3 и ключ намерения intentKey = 42:3:publish. Ключ включает эффект, потому что одна revision может участвовать не только в публикации. Производный ключ допустим лишь при явном доменном правиле: для одной заметки и revision разрешён один publish. Если одинаковая revision может публиковаться дважды с разным смыслом, ключ нужно выдавать на границе операции, а не вычислять из данных.

\n
Симптом, граница и безопасный следующий шаг
НаблюдениеВероятная границаПроверкаДо проверки нельзя
Нет noteId или revisionНеполное наблюдениеСверить запрос, ответ и журнал операцииПовторять запись
Есть intent, нет канонической записиWrite pathПроверить commit и порядок записиЗапускать relay
Есть note, нет intentWrite → publishПроверить правило создания intent для точной revisionСоздавать новую note
Intent pendingRelay pathНайти owner, ключ и статус обработкиМенять source
Intent применён, projection стараяRead modelСравнить key, revision и результат consumerДелать широкий replay
Projection свежая, public read пустRead contractПроверить scope, filter, права, кеш и queryПовторять relay
\n

Таблица ограничивает следующий шаг, но не ставит диагноз по одному признаку. Пока каноническая запись не подтверждена, очередь не является доказанной причиной. Если projection уже содержит revision 3, повтор relay не объяснит пустой публичный список. В этом случае владелец расследования меняется: нужно проверять контракт чтения.

\n

Архитектурное решение как проверяемая запись

\n

Запись решения должна отвечать на пять вопросов: какой контекст принят, какой вариант выбран, какие требования обязательны, какие допущения сделаны и когда вариант пересматривают. Фраза «используем outbox» слишком коротка. Outbox — это способ сохранить намерение рядом с изменением, но сама надпись не показывает границу транзакции, владельца обработки или политику повтора.

\n
Decision: 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

Канонический write и intent нельзя смешивать

\n

Если заметка уже записана, а intent отсутствует, нужно вернуться к границе write → publish. Проверяют commit, обработчик после записи, правило публикации и разрешённый способ восстановления. Новую заметку с другим id не создают: исходная останется без intent, а новая начнёт независимую причинную цепочку.

\n

Если note и intent пишутся в одной транзакции, команда должна подтвердить это свойствами выбранного хранилища, а не двумя успешными ответами разных API. Если записи разделены, успешность одной операции не является доказательством атомарности другой. Именно это допущение чаще всего скрывает источник необратимой ошибки.

\n
const 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 должны быть защищены транзакцией или уникальным ограничением хранилища.

\n

Pending не означает потерю

\n

Pending означает, что разрешённое намерение существует, но подтверждение read effect ещё не найдено. Evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, состояние нельзя назвать pending по интуиции: сначала проверяют, была ли запись intent.

\n

Пока relay не подтвердил результат, безопасное действие — сохранить source и собрать недостающие данные. Нельзя очищать source, менять revision или отправлять широкий replay «на всякий случай». Такие операции уничтожают исходный контекст и могут повторить побочный эффект для нескольких записей.

\n
Дерево диагностики: решение, каноническая запись, intent, relay, проекция и публичный контракт чтения
Причину меняют только после подтверждения предыдущего состояния.
\n

Когда допустим controlled replay

\n

Повторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат первой попытки. Relay проверяет ключ до эффекта. Уже записанный ключ означает повтор того же намерения, а не разрешение создать вторую проекцию.

\n

AWS Builders’ Library описывает ту же границу для идемпотентных API: повтор должен иметь устойчивый идентификатор, а сервер должен отличать повторное обращение от нового намерения. Для части операций AWS связывает запись идентификатора и изменение ресурса атомарной операцией. В нашем кейсе это не готовая реализация, а критерий, с которым сравнивают проектное решение.

\n

Нельзя считать повтором запрос только потому, что его параметры совпали. Владелец мог дважды намеренно сохранить одинаковый текст. Поэтому ключ должен выражать семантику операции. Если сервер видит тот же ключ с другим payload, он возвращает конфликт, а не молча принимает вторую версию.

\n

Старая доставка также не должна затирать новую проекцию. Если в projection уже стоит revision 4, событие для revision 3 останавливают и сохраняют конфликт. Идемпотентность — это не подавление ошибок, а повторяемое решение для одного ключа при сохранении несовместимых состояний.

\n

Порядок действий

\n
  1. Зафиксировать симптом: запрос, время, noteId, ожидаемую revision, scope, filter, права и ответ public read.
  2. Проверить запись решения: контекст, вариант, MUST/MUST NOT, допущение о границе и критерий пересмотра.
  3. Проверить каноническую note по noteId и revision. До этого не создавать замену и не менять source.
  4. Проверить intent для точного ключа noteId:revision:publish. Если ключа нет, исследовать write → publish, а не очередь.
  5. Если intent pending, найти owner relay и сохранённое evidence обработки. Source оставить неизменным.
  6. Если intent применён, сравнить revision projection с revision source и результат consumer.
  7. Если нужен replay, ограничить его одним intentKey, одной revision и одним известным consumer.
  8. Если projection свежая, закончить ветку relay и проверить read contract: scope, filter, tenant, права, кеш, формат id и query.
  9. Записать результат и только затем выполнить обратимое действие на найденной границе.
\n

Порядок защищает расследование от самоподтверждающейся ошибки. Каждое изменение состояния может скрыть первопричину, поэтому сначала сохраняют доказательства, затем выбирают узкую операцию. После действия повторяют тот же read contract и сравнивают revision, а не только HTTP-статус.

\n

Ограничения и критерий готовности

\n

Схема не делает отложенную согласованность мгновенной и не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если public read обязан быть строго синхронным, отложенная проекция через outbox может не соответствовать требованию. Вариант пересматривают, а не маскируют несовпадение дополнительными retry.

\n

Учебный код не учитывает несколько процессов, конкурентные записи, рестарт, сетевой timeout и повтор после неизвестного результата. Политику хранения ключей выбирают по сроку возможного повтора и риску эффекта. Для реального проекта отдельно проверяют уникальность ключа, TTL, конфликт payload и поведение после частичного commit.

\n

Разбор готов, когда другой инженер может повторить его без устного контекста. В записи видны исходный симптом, noteId, revision, intentKey, граница причины, evidence, владелец следующего шага и разрешённое действие. Критерий для доставки: две одинаковые доставки одного ключа дают не более одного эффекта, а старая revision не затирает новую projection. Критерий для пустого чтения: после подтверждения свежей projection найдено объяснение в scope, filter, правах, кеше или query.

\n

Проверяемые источники

\n" }