diff --git a/editorial/agent-rewrites/218.json b/editorial/agent-rewrites/218.json index 91e9eb5..b8edef4 100644 --- a/editorial/agent-rewrites/218.json +++ b/editorial/agent-rewrites/218.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-12-mechanism-architecture-review", "title": "Архитектурное решение как проверяемый контракт: запись, повтор и откат", "excerpt": "Схема не объясняет, что происходит после сбоя. Разбираем, как связать требования, evidence и assumption, отделить запись намерения от публичного эффекта и проверить безопасный повтор.", - "contentHtml": "

Симптом появляется после первой ошибки: рабочая заметка сохранена, но публичная версия не обновилась. Команда повторяет отправку. Иногда читатель получает свежий текст, иногда появляются две публикации, а иногда повтор скрывает исходную причину. На схеме стрелка «записать → отправить» выглядит одной операцией, хотя между ними уже есть граница хранения, очередь или отдельный процесс.

\n

Цена ошибки — не только лишний запрос. Без отдельного следа намерения нельзя понять, была ли публикация разрешена, потерялось ли сообщение или уже сломался public read. Нельзя безопасно выбрать retry и нельзя доказать, что повтор не создаст второй эффект.

\n

Тезис статьи простой: архитектурное решение должно описывать не любимую технологию, а проверяемый контракт. В нём есть контекст, требования, варианты, evidence, assumption, последствия и обратимый путь выхода. Для публикации заметки это означает три разные сущности: каноническую запись, intent публикации и публичную проекцию.

\n

Что именно нужно зафиксировать

\n

Каноническая запись принадлежит write path. Она хранит исходную заметку и её revision. Intent означает: для этой revision разрешено создать публичный эффект. Projection принадлежит read path. Она может появиться позже и иметь собственное состояние.

\n

Такое разделение не делает систему надёжной автоматически. Оно делает отказ различимым. До relay видны note и pending intent. После relay можно сравнить ключ, revision и projection. Если projection уже совпадает с note, повторная обработка intent не отвечает на проблему читателя: нужно проверить query, фильтр, права или cache.

\n

Сначала запишите требования словами, которые можно проверить. В учебном кейсе они такие: каноническая запись существует; intent сохранён отдельно; public read независим; задержка чтения допустима и видима; повтор использует ключ. Отдельно запишите non-goals: распределённая транзакция, выбор брокера, production-SLO и exactly-once delivery не следуют из слова outbox.

\n
Минимальный контракт архитектурного решения
ПолеЧто фиксируетПроверкаОграничение
ContextКакая запись и какой public read расходятсяНазваны source, reader и граница публикацииНе описывает всю платформу
RequirementОбязательное свойство потокаУ свойства есть наблюдение или assertionНе является пожеланием команды
EvidenceФакт и область его наблюденияУказаны claim, источник и scopeНе подтверждает соседнюю систему
AssumptionНепроверенное условие и цена ошибкиЕсть consequence, если условие ложноНельзя выдавать за гарантию
ReversibilityКак остановить или заменить вариантНазвано действие без удаления sourceОткат требует проверки среды
\n

Механизм: requirements сначала, вариант потом

\n

Сравнивать архитектуры по общему score опасно. Score скрывает потерянное условие. Лучше вернуть gaps — конкретные требования, которые вариант не закрывает.

\n
const brief = {\n  requirements: [\n    'canonical-write-record',\n    'recorded-publication-intent',\n    'independent-public-read',\n    'delayed-read-is-explicit',\n    'replay-key-is-explicit',\n  ],\n};\n\nfunction compare(option) {\n  const gaps = brief.requirements\n    .filter((name) => !option.supports.includes(name));\n\n  return {\n    id: option.id,\n    accepted: gaps.length === 0,\n    gaps,\n    reversibleBy: option.reversibleBy,\n  };\n}
\n

Рассмотрим три варианта. Синхронная запись с проекцией подходит, если read model локальна и её обновление входит в одну понятную границу. Она не закрывает текущий brief, если intent обязан пережить сбой отдельно. Синхронный query сохраняет меньше состояний, но не даёт независимого public read. Transactional outbox закрывает brief, если БД действительно записывает note и intent в одной локальной транзакции, а relay работает после commit.

\n

Последнее условие нельзя получить из примера на JavaScript. Небольшая функция с двумя `Map` показывает policy, но не моделирует блокировки, crash window, commit, брокер, сеть или права. Поэтому в решении нужно написать assumption: «учебная операция применяет пару note и intent вместе». Consequence звучит жёстко: если реальное хранилище не даёт такую границу, выбранный вариант нельзя переносить без нового разбора.

\n
\"Требования
Схема показывает владельцев состояния и место, где решение можно опровергнуть.
\n

Запись намерения не равна публикации

\n

Write path должен сохранить две связанные записи: note с `id` и `revision`, затем intent с ключом `noteId:revision`. Public projection не создаётся внутри этой операции. Она появляется только после обработки intent.

\n
function commitPublication(state, decisionId, note) {\n  if (!state.decisions.has(decisionId)) {\n    return { ok: false, reason: 'decision-missing' };\n  }\n\n  const key = `${note.id}:${note.revision}`;\n  if (state.intents.has(key)) {\n    return { ok: false, reason: 'intent-already-recorded' };\n  }\n\n  const notes = new Map(state.notes);\n  const intents = new Map(state.intents);\n  notes.set(note.id, note);\n  intents.set(key, { key, noteId: note.id, revision: note.revision, status: 'pending' });\n  state.notes = notes;\n  state.intents = intents;\n\n  return { ok: true, key };\n}\n\nfunction relayPublication(state, key) {\n  if (state.acceptedKeys.has(key)) {\n    return { status: 'duplicate-relay-suppressed' };\n  }\n\n  const intent = state.intents.get(key);\n  const note = intent && state.notes.get(intent.noteId);\n  if (!intent || !note || note.revision !== intent.revision) {\n    return { status: 'source-or-revision-mismatch' };\n  }\n\n  state.projection.set(note.id, { ...note });\n  state.acceptedKeys.add(key);\n  return { status: 'projection-recorded' };\n}
\n

Ключ защищает только этот effect внутри описанной модели. Он не делает внешний email, webhook или платёж идемпотентным. Если relay успел вызвать внешний сервис, а затем упал до записи receipt, повтор может снова вызвать внешний effect. Для каждого consumer нужен отдельный контракт: его ключ, срок хранения, ответ на конфликт и проверка результата.

\n

Важен и отрицательный путь. Нет decision — write path останавливается. Нет canonical note — relay не создаёт замену с новым id. Нет intent — нельзя лечить проблему повторной отправкой из UI. Pending intent — нужно проверить relay и его владельца. Projection с правильной revision — нужно закончить эту ветку и исследовать read contract.

\n

Симптомы и действия

\n

Диагностика должна сохранять состояние до исправления. Не удаляйте source, пока не записаны decision id, note id, revision, intent key и observed public read. Иначе повторная попытка может убрать единственное evidence.

\n
Диагностика потока публикации
СимптомПричинаПроверкаДействие
Нет записи в public readIntent ещё pendingНайти точный ключ и статус relayПроверить worker и сохранить intent
Повтор создаёт две проекцииНет стабильного ключа или receiptСравнить key, revision и историю effectДобавить idempotency contract consumer
Intent есть, note нетWrite boundary не атомарнаСопоставить commit и запись обеих сущностейОстановить relay; пересмотреть storage boundary
Projection совпадает, но UI старыйПроблема в query, cache, scope или правахПрочитать projection прямым запросом и повторить public queryПередать расследование owner read path
Revision не совпадаетСтарый intent или гонка версийСравнить note, intent и projection по revisionНе применять старый intent к новой записи
\n

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

\n
  1. Зафиксируйте один симптом: какая заметка не видна, какая revision ожидается и какой public read проверяли.
  2. Назовите владельцев: source of truth, write path, intent store, relay и public read.
  3. Сформулируйте requirements и non-goals. Не подменяйте требование названием технологии.
  4. Сравните минимум три допустимых варианта по gaps, а для каждого запишите обратимое действие.
  5. Разделите evidence и assumption. Для assumption укажите consequence и условие пересмотра.
  6. Зафиксируйте decision до реализации. Если выбранный вариант не закрывает requirement, остановите работу.
  7. Проверьте write path на паре note плюс intent. В реальной БД подтвердите границу транзакции, а не переносите вывод из `Map`.
  8. Проверьте relay по ключу `noteId:revision`. Повтор того же ключа должен иметь явно заданный результат.
  9. Пройдите отрицательные ветки: missing decision, missing note, missing intent, pending relay, stale revision и projection без видимости в UI.
  10. Остановите изменение, если результат не наблюдаем. Сначала добавьте evidence, затем выбирайте corrective action.
\n

Ограничения и отрицательный путь

\n

Transactional outbox не является универсальным ответом. Он добавляет состояния, таблицу или журнал intent, relay, retry policy, receipt и наблюдение. Если public read может читать каноническую запись без отдельной задержки, синхронный query может быть дешевле. Если projection локальна и ошибка её обновления не требует отдельного recovery path, синхронная запись может быть достаточной.

\n

Не называйте локальную пару записей распределённой транзакцией. Не обещайте exactly-once, если внешний consumer не предъявляет receipt и правило дедупликации. Не используйте retry без ответа на вопрос, какой повтор безопасен. AWS отдельно связывает повтор с идемпотентностью операции; это не означает, что любой endpoint безопасен для повторного вызова.

\n

Ключ `noteId:revision` защищает одну версию заметки. Он не решает конфликт двух разных намерений, изменение схемы, истечение retention, ручное исправление projection или зависимость от времени. Такие условия должны попасть в следующий decision или в контракт конкретного consumer.

\n

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

\n

Решение готово к реализации, если другой инженер без устного контекста может назвать source of truth, requirement, выбранный вариант, каждый gap альтернатив, evidence и assumption. Для одного потока он может показать note, intent key, статус relay и projection revision. Повтор известного ключа имеет проверяемый результат. При отсутствии decision, note или intent система не создаёт замену молча. Если projection совпадает с source, расследование переключается на read contract.

\n

Это критерий формы решения, а не production-результат. Его нужно подтвердить тестом на конкретном хранилище и consumer, затем отдельно измерить ошибки, задержки и recovery. До этого архитектура остаётся условной и должна так называться.

\n

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

" + "contentHtml": "

В учебном потоке симптом появляется после первой ошибки: рабочая заметка сохранена, но публичная версия не обновилась. Команда повторяет отправку. Иногда читатель получает свежий текст, иногда появляются две публикации, а иногда повтор скрывает исходную причину. На схеме стрелка «записать → отправить» выглядит одной операцией, хотя между ними уже есть граница хранения, очередь или отдельный процесс.

\n

Цена ошибки — не только лишний запрос. Без отдельного следа намерения нельзя понять, была ли публикация разрешена, потерялось ли сообщение или уже сломался public read. Нельзя безопасно выбрать retry и нельзя доказать, что повтор не создаст второй эффект.

\n

Тезис статьи простой: архитектурное решение должно описывать не любимую технологию, а проверяемый контракт. В нём есть контекст, требования, варианты, evidence, assumption, последствия и обратимый путь выхода. Для публикации заметки это означает три разные сущности: каноническую запись, intent публикации и публичную проекцию.

\n

Что именно нужно зафиксировать

\n

Каноническая запись принадлежит write path. Она хранит исходную заметку и её revision. Intent означает: для этой revision разрешено создать публичный эффект. Projection принадлежит read path. Она может появиться позже и иметь собственное состояние.

\n

Такое разделение не делает систему надёжной автоматически. Оно делает отказ различимым. До relay видны note и pending intent. После relay можно сравнить ключ, revision и projection. Если projection уже совпадает с note, повторная обработка intent не отвечает на проблему читателя: нужно проверить query, фильтр, права или cache.

\n

Сначала запишите требования словами, которые можно проверить. В учебном кейсе они такие: каноническая запись существует; intent сохранён отдельно; public read независим; задержка чтения допустима и видима; повтор использует ключ. Отдельно запишите non-goals: распределённая транзакция, выбор брокера, production-SLO и exactly-once delivery не следуют из слова outbox.

\n
Минимальный контракт архитектурного решения
ПолеЧто фиксируетПроверкаОграничение
ContextКакая запись и какой public read расходятсяНазваны source, reader и граница публикацииНе описывает всю платформу
RequirementОбязательное свойство потокаУ свойства есть наблюдение или assertionНе является пожеланием команды
EvidenceФакт и область его наблюденияУказаны claim, источник и scopeНе подтверждает соседнюю систему
AssumptionНепроверенное условие и цена ошибкиЕсть consequence, если условие ложноНельзя выдавать за гарантию
ReversibilityКак остановить или заменить вариантНазвано действие без удаления sourceОткат требует проверки среды
\n

Механизм: requirements сначала, вариант потом

\n

Сравнивать архитектуры по общему score опасно. Score скрывает потерянное условие. Лучше вернуть gaps — конкретные требования, которые вариант не закрывает. Если требования записываются как MUST, SHOULD и MAY, эти слова задают уровень обязательности, но не превращают assumption в evidence.

\n
const brief = {\n  requirements: [\n    'canonical-write-record',\n    'recorded-publication-intent',\n    'independent-public-read',\n    'delayed-read-is-explicit',\n    'replay-key-is-explicit',\n  ],\n};\n\nfunction compare(option) {\n  const gaps = brief.requirements\n    .filter((name) => !option.supports.includes(name));\n\n  return {\n    id: option.id,\n    accepted: gaps.length === 0,\n    gaps,\n    reversibleBy: option.reversibleBy,\n  };\n}
\n

Рассмотрим три варианта. Синхронная запись с проекцией подходит, если read model локальна и её обновление входит в одну понятную границу. Она не закрывает текущий brief, если intent обязан пережить сбой отдельно. Синхронный query сохраняет меньше состояний, но не даёт независимого public read. Transactional outbox закрывает brief, если БД действительно записывает note и intent в одной локальной транзакции, а relay работает после commit.

\n

Последнее условие нельзя получить из примера на JavaScript. Небольшая функция с двумя Map показывает policy, но не моделирует блокировки, crash window, commit, брокер, сеть или права. Поэтому в решении нужно написать assumption: «учебная операция применяет пару note и intent вместе». Consequence звучит жёстко: если реальное хранилище не даёт такую границу, выбранный вариант нельзя переносить без нового разбора.

\n
\"Требования
Схема показывает владельцев состояния и место, где решение можно опровергнуть.
\n

Запись намерения не равна публикации

\n

Write path должен сохранить две связанные записи: note с id и revision, затем intent с ключом noteId:revision. Public projection не создаётся внутри этой операции. Она появляется только после обработки intent. Для immutable-истории хранилище в примере индексирует note по тому же ключу, а не перезаписывает новую revision поверх старой.

\n
function createState() {\n  return {\n    decisions: new Set(['decision-1']),\n    notes: new Map(),\n    intents: new Map(),\n    projection: new Map(),\n    acceptedKeys: new Set(),\n  };\n}\n\nfunction revisionKey(note) {\n  return note.id + ':' + note.revision;\n}\n\nfunction commitPublication(state, decisionId, note) {\n  if (!state.decisions.has(decisionId)) {\n    return { ok: false, reason: 'decision-missing' };\n  }\n\n  const key = revisionKey(note);\n  if (state.intents.has(key)) {\n    return { ok: false, reason: 'intent-already-recorded' };\n  }\n\n  const notes = new Map(state.notes);\n  const intents = new Map(state.intents);\n  notes.set(key, note);\n  intents.set(key, { key, noteId: note.id, revision: note.revision, status: 'pending' });\n  state.notes = notes;\n  state.intents = intents;\n\n  return { ok: true, key };\n}\n\nfunction relayPublication(state, key) {\n  if (state.acceptedKeys.has(key)) {\n    return { status: 'duplicate-relay-suppressed' };\n  }\n\n  const intent = state.intents.get(key);\n  const note = intent && state.notes.get(key);\n  if (!intent || !note || note.id !== intent.noteId || note.revision !== intent.revision) {\n    return { status: 'source-or-revision-mismatch' };\n  }\n\n  state.projection.set(key, { ...note });\n  state.acceptedKeys.add(key);\n  return { status: 'projection-recorded' };\n}\n\nconst state = createState();\nconst note = { id: 'note-7', revision: 3, body: 'Учебная заметка' };\nconst first = commitPublication(state, 'decision-1', note);\nconst published = relayPublication(state, first.key);\nconst replay = relayPublication(state, first.key);\n// projection-recorded, duplicate-relay-suppressed
\n

Здесь notes и projection индексируются по noteId:revision. Поэтому сохранённая revision не исчезает при появлении следующей. Пример воспроизводим после вставки в обычный JavaScript runtime: он не требует БД или сети, а два последних результата показывают ожидаемое поведение первого relay и его повтора.

\n

Ключ защищает только этот effect внутри описанной модели. Он не делает внешний email, webhook или платёж идемпотентным. Если relay успел вызвать внешний сервис, а затем упал до записи receipt, повтор может снова вызвать внешний effect. Для каждого consumer нужен отдельный контракт: его ключ, срок хранения, ответ на конфликт и проверка результата.

\n

Важен и отрицательный путь. Нет decision — write path останавливается. Нет canonical note — relay не создаёт замену с новым id. Нет intent — нельзя лечить проблему повторной отправкой из UI. Pending intent — нужно проверить relay и его владельца. Projection с правильной revision — нужно закончить эту ветку и исследовать read contract.

\n

Симптомы и действия

\n

Диагностика должна сохранять состояние до исправления. Не удаляйте source, пока не записаны decision id, note id, revision, intent key и observed public read. Иначе повторная попытка может убрать единственное evidence.

\n
Диагностика потока публикации
СимптомПричинаПроверкаДействие
Нет записи в public readIntent ещё pendingНайти точный ключ и статус relayПроверить worker и сохранить intent
Повтор создаёт две проекцииНет стабильного ключа или receiptСравнить key, revision и историю effectДобавить idempotency contract consumer
Intent есть, note нетWrite boundary не атомарнаСопоставить commit и запись обеих сущностейОстановить relay; пересмотреть storage boundary
Projection совпадает, но UI старыйПроблема в query, cache, scope или правахПрочитать projection прямым запросом и повторить public queryПередать расследование owner read path
Revision не совпадаетСтарый intent или гонка версийСравнить note, intent и projection по revisionНе применять старый intent к новой записи
\n

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

\n
  1. Зафиксируйте один симптом: какая заметка не видна, какая revision ожидается и какой public read проверяли.
  2. Назовите владельцев: source of truth, write path, intent store, relay и public read.
  3. Сформулируйте requirements и non-goals. Не подменяйте требование названием технологии.
  4. Сравните минимум три допустимых варианта по gaps, а для каждого запишите обратимое действие.
  5. Разделите evidence и assumption. Для assumption укажите consequence и условие пересмотра.
  6. Зафиксируйте decision до реализации. Если выбранный вариант не закрывает requirement, остановите работу.
  7. Проверьте write path на паре note плюс intent. В реальной БД подтвердите границу транзакции, а не переносите вывод из Map.
  8. Проверьте relay по ключу noteId:revision. Повтор того же ключа должен иметь явно заданный результат.
  9. Пройдите отрицательные ветки: missing decision, missing note, missing intent, pending relay, stale revision и projection без видимости в UI.
  10. Остановите изменение, если результат не наблюдаем. Сначала добавьте evidence, затем выбирайте corrective action.
\n

Ограничения и отрицательный путь

\n

Transactional outbox не является универсальным ответом. Он добавляет состояния, таблицу или журнал intent, relay, retry policy, receipt и наблюдение. Если public read может читать каноническую запись без отдельной задержки, синхронный query может быть дешевле. Если projection локальна и ошибка её обновления не требует отдельного recovery path, синхронная запись может быть достаточной.

\n

Не называйте локальную пару записей распределённой транзакцией. Не обещайте exactly-once, если внешний consumer не предъявляет receipt и правило дедупликации. Не используйте retry без ответа на вопрос, какой повтор безопасен. AWS описывает повтор как безопасный только при контракте, который исключает нежелательный дополнительный эффект; это не означает, что любой endpoint безопасен для повторного вызова.

\n

Ключ noteId:revision защищает одну версию заметки. Он не решает конфликт двух разных намерений, изменение схемы, истечение retention, ручное исправление projection или зависимость от времени. Такие условия должны попасть в следующий decision или в контракт конкретного consumer.

\n

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

\n

Решение готово к реализации, если другой инженер без устного контекста может назвать source of truth, requirement, выбранный вариант, каждый gap альтернатив, evidence и assumption. Для одного потока он может показать note, intent key, статус relay и projection revision. Повтор известного ключа имеет проверяемый результат. При отсутствии decision, note или intent система не создаёт замену молча. Если projection совпадает с source, расследование переключается на read contract.

\n

Это критерий формы решения, а не production-результат. Его нужно подтвердить тестом на конкретном хранилище и consumer, затем отдельно измерить ошибки, задержки и recovery. До этого архитектура остаётся условной и должна так называться.

\n

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

" }