8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 218,
|
||
"slug": "editorial-2021-12-mechanism-architecture-review",
|
||
"title": "Архитектурное решение как проверяемый контракт: запись, повтор и откат",
|
||
"excerpt": "Схема не объясняет, что происходит после сбоя. Разбираем, как связать требования, evidence и assumption, отделить запись намерения от публичного эффекта и проверить безопасный повтор.",
|
||
"contentHtml": "<p>Симптом появляется после первой ошибки: рабочая заметка сохранена, но публичная версия не обновилась. Команда повторяет отправку. Иногда читатель получает свежий текст, иногда появляются две публикации, а иногда повтор скрывает исходную причину. На схеме стрелка «записать → отправить» выглядит одной операцией, хотя между ними уже есть граница хранения, очередь или отдельный процесс.</p>\n<p>Цена ошибки — не только лишний запрос. Без отдельного следа намерения нельзя понять, была ли публикация разрешена, потерялось ли сообщение или уже сломался public read. Нельзя безопасно выбрать retry и нельзя доказать, что повтор не создаст второй эффект.</p>\n<p>Тезис статьи простой: архитектурное решение должно описывать не любимую технологию, а проверяемый контракт. В нём есть контекст, требования, варианты, evidence, assumption, последствия и обратимый путь выхода. Для публикации заметки это означает три разные сущности: каноническую запись, intent публикации и публичную проекцию.</p>\n<h2>Что именно нужно зафиксировать</h2>\n<p>Каноническая запись принадлежит write path. Она хранит исходную заметку и её revision. Intent означает: для этой revision разрешено создать публичный эффект. Projection принадлежит read path. Она может появиться позже и иметь собственное состояние.</p>\n<p>Такое разделение не делает систему надёжной автоматически. Оно делает отказ различимым. До relay видны note и pending intent. После relay можно сравнить ключ, revision и projection. Если projection уже совпадает с note, повторная обработка intent не отвечает на проблему читателя: нужно проверить query, фильтр, права или cache.</p>\n<p>Сначала запишите требования словами, которые можно проверить. В учебном кейсе они такие: каноническая запись существует; intent сохранён отдельно; public read независим; задержка чтения допустима и видима; повтор использует ключ. Отдельно запишите non-goals: распределённая транзакция, выбор брокера, production-SLO и exactly-once delivery не следуют из слова outbox.</p>\n<div class=\"table-scroll\"><table><caption>Минимальный контракт архитектурного решения</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Что фиксирует</th><th scope=\"col\">Проверка</th><th scope=\"col\">Ограничение</th></tr></thead><tbody><tr><td>Context</td><td>Какая запись и какой public read расходятся</td><td>Названы source, reader и граница публикации</td><td>Не описывает всю платформу</td></tr><tr><td>Requirement</td><td>Обязательное свойство потока</td><td>У свойства есть наблюдение или assertion</td><td>Не является пожеланием команды</td></tr><tr><td>Evidence</td><td>Факт и область его наблюдения</td><td>Указаны claim, источник и scope</td><td>Не подтверждает соседнюю систему</td></tr><tr><td>Assumption</td><td>Непроверенное условие и цена ошибки</td><td>Есть consequence, если условие ложно</td><td>Нельзя выдавать за гарантию</td></tr><tr><td>Reversibility</td><td>Как остановить или заменить вариант</td><td>Названо действие без удаления source</td><td>Откат требует проверки среды</td></tr></tbody></table></div>\n<h2>Механизм: requirements сначала, вариант потом</h2>\n<p>Сравнивать архитектуры по общему score опасно. Score скрывает потерянное условие. Лучше вернуть gaps — конкретные требования, которые вариант не закрывает.</p>\n<pre><code>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}</code></pre>\n<p>Рассмотрим три варианта. Синхронная запись с проекцией подходит, если read model локальна и её обновление входит в одну понятную границу. Она не закрывает текущий brief, если intent обязан пережить сбой отдельно. Синхронный query сохраняет меньше состояний, но не даёт независимого public read. Transactional outbox закрывает brief, если БД действительно записывает note и intent в одной локальной транзакции, а relay работает после commit.</p>\n<p>Последнее условие нельзя получить из примера на JavaScript. Небольшая функция с двумя `Map` показывает policy, но не моделирует блокировки, crash window, commit, брокер, сеть или права. Поэтому в решении нужно написать assumption: «учебная операция применяет пару note и intent вместе». Consequence звучит жёстко: если реальное хранилище не даёт такую границу, выбранный вариант нельзя переносить без нового разбора.</p>\n<figure><img src=\"/assets/editorial/2021/architecture-review-decision-2021.svg\" alt=\"Требования проходят через сравнение вариантов; выбранный поток записывает каноническую заметку и intent, затем relay создаёт публичную проекцию, а evidence и assumption остаются раздельными\" loading=\"lazy\" /><figcaption>Схема показывает владельцев состояния и место, где решение можно опровергнуть.</figcaption></figure>\n<h2>Запись намерения не равна публикации</h2>\n<p>Write path должен сохранить две связанные записи: note с `id` и `revision`, затем intent с ключом `noteId:revision`. Public projection не создаётся внутри этой операции. Она появляется только после обработки intent.</p>\n<pre><code>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}</code></pre>\n<p>Ключ защищает только этот effect внутри описанной модели. Он не делает внешний email, webhook или платёж идемпотентным. Если relay успел вызвать внешний сервис, а затем упал до записи receipt, повтор может снова вызвать внешний effect. Для каждого consumer нужен отдельный контракт: его ключ, срок хранения, ответ на конфликт и проверка результата.</p>\n<p>Важен и отрицательный путь. Нет decision — write path останавливается. Нет canonical note — relay не создаёт замену с новым id. Нет intent — нельзя лечить проблему повторной отправкой из UI. Pending intent — нужно проверить relay и его владельца. Projection с правильной revision — нужно закончить эту ветку и исследовать read contract.</p>\n<h2>Симптомы и действия</h2>\n<p>Диагностика должна сохранять состояние до исправления. Не удаляйте source, пока не записаны decision id, note id, revision, intent key и observed public read. Иначе повторная попытка может убрать единственное evidence.</p>\n<table><caption>Диагностика потока публикации</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Нет записи в public read</td><td>Intent ещё pending</td><td>Найти точный ключ и статус relay</td><td>Проверить worker и сохранить intent</td></tr><tr><td>Повтор создаёт две проекции</td><td>Нет стабильного ключа или receipt</td><td>Сравнить key, revision и историю effect</td><td>Добавить idempotency contract consumer</td></tr><tr><td>Intent есть, note нет</td><td>Write boundary не атомарна</td><td>Сопоставить commit и запись обеих сущностей</td><td>Остановить relay; пересмотреть storage boundary</td></tr><tr><td>Projection совпадает, но UI старый</td><td>Проблема в query, cache, scope или правах</td><td>Прочитать projection прямым запросом и повторить public query</td><td>Передать расследование owner read path</td></tr><tr><td>Revision не совпадает</td><td>Старый intent или гонка версий</td><td>Сравнить note, intent и projection по revision</td><td>Не применять старый intent к новой записи</td></tr></tbody></table>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте один симптом: какая заметка не видна, какая revision ожидается и какой public read проверяли.</li><li>Назовите владельцев: source of truth, write path, intent store, relay и public read.</li><li>Сформулируйте requirements и non-goals. Не подменяйте требование названием технологии.</li><li>Сравните минимум три допустимых варианта по gaps, а для каждого запишите обратимое действие.</li><li>Разделите evidence и assumption. Для assumption укажите consequence и условие пересмотра.</li><li>Зафиксируйте decision до реализации. Если выбранный вариант не закрывает requirement, остановите работу.</li><li>Проверьте write path на паре note плюс intent. В реальной БД подтвердите границу транзакции, а не переносите вывод из `Map`.</li><li>Проверьте relay по ключу `noteId:revision`. Повтор того же ключа должен иметь явно заданный результат.</li><li>Пройдите отрицательные ветки: missing decision, missing note, missing intent, pending relay, stale revision и projection без видимости в UI.</li><li>Остановите изменение, если результат не наблюдаем. Сначала добавьте evidence, затем выбирайте corrective action.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Transactional outbox не является универсальным ответом. Он добавляет состояния, таблицу или журнал intent, relay, retry policy, receipt и наблюдение. Если public read может читать каноническую запись без отдельной задержки, синхронный query может быть дешевле. Если projection локальна и ошибка её обновления не требует отдельного recovery path, синхронная запись может быть достаточной.</p>\n<p>Не называйте локальную пару записей распределённой транзакцией. Не обещайте exactly-once, если внешний consumer не предъявляет receipt и правило дедупликации. Не используйте retry без ответа на вопрос, какой повтор безопасен. AWS отдельно связывает повтор с идемпотентностью операции; это не означает, что любой endpoint безопасен для повторного вызова.</p>\n<p>Ключ `noteId:revision` защищает одну версию заметки. Он не решает конфликт двух разных намерений, изменение схемы, истечение retention, ручное исправление projection или зависимость от времени. Такие условия должны попасть в следующий decision или в контракт конкретного consumer.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Решение готово к реализации, если другой инженер без устного контекста может назвать source of truth, requirement, выбранный вариант, каждый gap альтернатив, evidence и assumption. Для одного потока он может показать note, intent key, статус relay и projection revision. Повтор известного ключа имеет проверяемый результат. При отсутствии decision, note или intent система не создаёт замену молча. Если projection совпадает с source, расследование переключается на read contract.</p>\n<p>Это критерий формы решения, а не production-результат. Его нужно подтвердить тестом на конкретном хранилище и consumer, затем отдельно измерить ошибки, задержки и recovery. До этого архитектура остаётся условной и должна так называться.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/\" target=\"_blank\" rel=\"noopener noreferrer\">AWS Builders’ Library: Making retries safe with idempotent APIs</a> — официальный материал AWS о семантике повторов, client request ID и защите от повторного эффекта.</li><li><a href=\"https://www.postgresql.org/docs/current/transaction-iso.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Transaction Isolation</a> — официальная документация о видимости данных, конкуренции и необходимости повторить транзакцию после serialization failure; пример статьи не утверждает свойства PostgreSQL без проверки.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc2119.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 2119: Key words for use in RFCs to Indicate Requirement Levels</a> — нормативный источник для различения MUST, SHOULD и MAY; в статье эти слова обозначают только контракт учебного примера.</li></ul>"
|
||
}
|