Files
progcode/editorial/agent-rewrites/217.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 217,
"slug": "editorial-2021-12-field-architecture-review",
"title": "Как проверить архитектурное решение, пока ошибка ещё обратима",
"excerpt": "Пошаговая диагностика случая, когда запись сохранена, но публичное чтение её не показывает. Разбираем границы решения, канонический write, intent, повторную доставку и read contract.",
"contentHtml": "<p>Симптом выглядит просто: пользователь сохраняет заметку, получает успешный ответ, а затем не видит её в списке или по публичной ссылке. Команда сразу подозревает очередь, кеш или базу и повторяет операцию. Это опасный первый шаг. Повтор может создать вторую запись, второй intent или две проекции. После этого уже трудно установить, какая операция была исходной и на какой границе возникла ошибка. Цена ошибки — потеря версии, двойной побочный эффект и более дорогое расследование.</p>\n<p>Тезис статьи простой: архитектурное решение нужно проверять как причинную цепочку, а не как набор технологий. Сначала фиксируют наблюдаемый факт и идентификаторы. Затем отдельно проверяют решение, каноническую запись, намерение публикации, доставку и публичный контракт чтения. На каждой границе должно быть понятно, какое действие разрешено, а какое запрещено.</p>\n<h2>Механизм: одна запись, несколько границ</h2>\n<p>Сохранение данных и их публичное чтение часто проходят через разные состояния. Каноническая запись хранит источник истины. Intent сообщает, что для неё нужно выполнить следующий эффект. Relay переносит intent в read model. Публичный запрос читает уже проекцию, поиск или кеш. Успех на одной границе не доказывает успех на другой.</p>\n<p>Для диагностики нужна пара идентификаторов: <code>noteId</code> и <code>revision</code>. Один и тот же объект может иметь несколько версий. Если повторять действие только по заголовку или времени, система не отличит новую версию от повторной доставки старой. Для побочного эффекта нужен отдельный стабильный ключ. В учебном примере ниже ключ строится из идентификатора записи и версии:</p>\n<pre><code>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// но не заменяет транзакцию, очередь или БД.</code></pre>\n<p>Такой ключ не решает проблему сам по себе. Consumer должен хранить информацию о принятом ключе и сравнивать версию проекции с версией источника. Если ключ уже применён, повторный вызов должен вернуть тот же смысловой результат, а не создать новый эффект. Если версия отличается, система должна остановиться и передать случай на проверку конфликта.</p>\n<h2>Сначала отделите факт от гипотезы</h2>\n<p>Фраза «публикация сломалась» уже содержит гипотезу. Факт короче: «после ответа 200 запрос GET /public/notes/42 не вернул revision 3 в 14:05:12». К факту добавляют способ чтения, область видимости, фильтры и момент проверки. Иначе команда сравнивает разные запросы и принимает различие контрактов за потерю данных.</p>\n<p>Минимальная карточка наблюдения должна отвечать на пять вопросов: какой объект изменяли, какую версию ожидали, где прочитали результат, что получили и какое состояние уже подтверждено. Не нужно сразу собирать все логи. Нужны данные, которые отделяют canonical write от relay и relay от read contract.</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>Нет noteId или revision</td><td>Наблюдение неполное</td><td>Сверить запрос, ответ и запись операции</td><td>Остановить повтор; восстановить идентификаторы</td></tr><tr><td>Есть intent, но нет канонической записи</td><td>Write path</td><td>Проверить commit source и порядок записи</td><td>Не запускать relay; исправить запись источника</td></tr><tr><td>Есть note, intent отсутствует</td><td>Граница write → publish</td><td>Проверить правило создания intent для точной revision</td><td>Не создавать копию; вернуть случай к границе записи</td></tr><tr><td>Intent pending</td><td>Relay path</td><td>Найти owner, ключ, статус job или receipt</td><td>Наблюдать обработку; source не менять</td></tr><tr><td>Intent применён, projection старая</td><td>Read projection</td><td>Сравнить key, revision и результат consumer</td><td>Разрешить controlled replay только выбранного ключа</td></tr><tr><td>Projection свежая, public read пуст</td><td>Read contract</td><td>Проверить scope, filter, права, кеш и query</td><td>Закончить ветку relay; исследовать запрос чтения</td></tr></tbody></table></div>\n<p>Таблица не ставит диагноз по одному признаку. Она ограничивает следующий шаг. Пока не найдено подтверждение канонической записи, нельзя обсуждать повторную доставку. Пока projection совпадает с источником, нельзя объявлять relay причиной пустого списка. Такой порядок сохраняет возможность отката и не смешивает владельцев разных компонентов.</p>\n<h2>Решение должно фиксировать границы</h2>\n<p>Архитектурная запись нужна не для длинного описания системы. Она фиксирует контекст, выбранный вариант, обязательные требования, допущения, последствия и способ пересмотра. Если команда записала только «используем outbox», она не ответила на главные вопросы: где заканчивается транзакция, кто читает intent, какой ключ считается идемпотентным и что делать при конфликте версий.</p>\n<pre><code>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</code></pre>\n<p>Это учебная форма записи. Она не объявляет распределённую транзакцию и не доказывает exactly-once delivery. Её задача — сделать проверяемыми условия и отрицательный путь. Если допущение не подтверждено, решение получает статус «нужно проверить», а не превращается в разрешение на изменение данных.</p>\n<p>Особенно полезно отделять обязательное требование от предпочтения. «Повтор не должен создавать двойной эффект» — требование. «Используем конкретный брокер» — вариант. Если выбранный брокер меняется, требование остаётся, а решение пересматривают по тем же проверкам. Так архитектура не привязывается к названию инструмента.</p>\n<h2>Канонический write и intent нельзя считать одним эффектом</h2>\n<p>В простом варианте запись note и intent выполняют в одной транзакционной границе. В более сложном варианте они могут попасть в разные хранилища или пройти через отдельный сервис. Тогда нужно честно назвать окно расхождения. Наличие двух успешных ответов от разных API не является доказательством общей атомарности.</p>\n<p>Если note есть, а intent отсутствует, сначала проверяют границу записи: правило публикации, commit, обработчик после записи и разрешённый способ восстановления. Не создают новую note с другим id. Иначе исходная запись останется без intent, а новая начнёт отдельную причинную цепочку. Это удваивает проблему вместо восстановления состояния.</p>\n<pre><code>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}</code></pre>\n<p>Код иллюстративный. Он показывает две проверки: решение должно быть принято до изменения, а ключ должен быть стабильным. Массивы в памяти не защищают от падения процесса, гонки или частичной записи. В рабочей системе эти свойства нужно выразить средствами выбранного хранилища и проверить отдельным тестом.</p>\n<h2>Pending не означает потерю</h2>\n<p>Состояние pending означает только одно: существует разрешённое намерение, для которого ещё нет подтверждённого read effect. В зависимости от системы evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, нельзя называть случай pending по интуиции. Нужно вернуться к write boundary.</p>\n<p>Нельзя очищать source, чтобы «запустить процесс заново». Нельзя менять revision, чтобы скрыть конфликт. Нельзя отправлять широкий replay без ограничения ключом. Пока owner relay не подтвердил результат, безопасное действие — сохранить исходное состояние и собрать недостающий evidence.</p>\n<figure><img src=\"/assets/editorial/2021/architecture-review-diagnosis-2021.svg\" alt=\"Дерево диагностики архитектурной публикации: решение, каноническая запись, intent, relay, проекция и публичный контракт чтения\" loading=\"lazy\" /><figcaption>Граница причины меняется только после подтверждения предыдущего состояния.</figcaption></figure>\n<h2>Когда допустим controlled replay</h2>\n<p>Повторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат предыдущей попытки. Relay должен проверять ключ до выполнения эффекта. Если ключ уже принят, consumer не создаёт вторую проекцию. Если ключ неизвестен, он применяет только заявленную версию и записывает результат.</p>\n<p>Replay не исправляет неверный контракт чтения. Если проекция уже содержит revision 3, но поиск её не возвращает, повтор relay не добавляет доказательств. Нужно проверить фильтр, tenant, права, кеш, формат идентификатора и конкретный query. Публичный список и прямое чтение по id могут иметь разные правила. Это отдельная причина, а не продолжение relay.</p>\n<p>Отрицательный путь важнее happy path. Если revision проекции новее источника, обработчик не должен молча откатывать её старой доставкой. Если один ключ связан с другим payload, обработчик не должен считать запрос повтором. Он должен вернуть конфликт и сохранить обе версии для разбора. Иначе идемпотентность превращается в тихое подавление ошибки.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать точный симптом: запрос, время, noteId, ожидаемую revision, scope и полученный ответ.</li><li>Проверить решение: есть ли контекст, требования, допущения, выбранный вариант и явный отрицательный путь.</li><li>Проверить каноническую запись по noteId и revision. Не создавать замену до завершения этой проверки.</li><li>Проверить intent для точного ключа noteId:revision. Если ключа нет, исследовать write boundary, а не очередь.</li><li>Если intent pending, найти owner relay и разрешённое evidence обработки. Source не изменять.</li><li>Если intent применён, сравнить revision проекции с revision источника. Replay ограничить одним ключом и одним известным consumer.</li><li>Если проекция свежая, проверить read contract: scope, filter, права, кеш, формат и query.</li><li>Записать результат проверки и только затем выполнить обратимое действие на найденной границе.</li></ol>\n<p>Порядок важен потому, что каждая операция меняет наблюдаемое состояние. Удаление или повтор записи до фиксации evidence уничтожает исходный контекст. Диагностика должна вести к меньшему числу возможных причин, а не создавать новые варианты.</p>\n<h2>Ограничения метода</h2>\n<p>Эта схема не делает eventual consistency мгновенной. Она не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если commit и intent находятся в разных хранилищах, нужно отдельно описать окно расхождения и способ сверки.</p>\n<p>Пример с Map и массивом применим только для объяснения формы состояний. Он не учитывает несколько процессов, конкурирующие записи, рестарт, сетевой timeout и повтор после неизвестного результата. В реальной системе идентификатор операции должен сохраняться достаточно долго, чтобы обработчик отличал поздний повтор от нового намерения. Политику хранения ключей выбирают по сроку возможного повтора и риску побочного эффекта.</p>\n<p>Метод не разрешает менять требования задним числом. Если выяснилось, что задержка недопустима, это новое требование к решению. Если public read должен быть строго синхронным, outbox с отложенной проекцией может не подходить. В таком случае фиксируют несовпадение и пересматривают вариант, а не маскируют его дополнительными retry.</p>\n<h2>Критерий готовности</h2>\n<p>Разбор готов, когда другой инженер может повторить его без устного контекста. В записи есть исходный симптом, идентификаторы, граница причины, evidence проверки, разрешённое действие и ограничение примера. Для конкретного случая должны быть видны noteId, revision, intentKey и результат public read. Для повторной доставки отдельно указаны owner, условие идемпотентности и ожидаемый результат повторного запроса.</p>\n<p>Проверяемый критерий можно сформулировать так: один и тот же intentKey при двух одинаковых доставках даёт не более одного эффекта, а доставка старой revision не затирает более новую проекцию. Для пустого public read критерий другой: после подтверждённой свежей проекции найдено объяснение в scope, filter, правах, кеше или query. Если ни один критерий нельзя проверить по сохранённому evidence, разбор ещё не закончен.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://c4model.com/\" target=\"_blank\" rel=\"noopener noreferrer\">C4 model: официальный сайт модели визуализации архитектуры</a> — описывает уровни system context, container, component и code. В статье используется только идея явных границ; пример не заявляет соответствие полной нотации C4.</li><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> — объясняет, почему повтор запроса должен отделяться от повторного побочного эффекта и зачем операции нужен устойчивый идентификатор.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8174.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8174: Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words</a> — уточняет применение нормативных слов MUST, SHOULD и MAY. В тексте они обозначают условия примера, а не требования к чужой системе.</li></ul>"
}