Files
progcode/editorial/agent-rewrites/217.json
T

8 lines
22 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>Проблема в учебном кейсе такова: пользователь сохраняет заметку, получает ответ 200, а затем не видит её в публичном списке. Прямой запрос по идентификатору тоже возвращает пустой результат. Команда подозревает очередь, кеш или базу и хочет повторить запись. Это опасный первый шаг: повтор может создать вторую заметку, второй intent или две проекции. После этого уже трудно установить исходную операцию и границу сбоя.</p>\n<p>Проверка архитектурного решения начинается с наблюдаемого факта и продолжается по причинной цепочке. Нужно отдельно проверить решение, каноническую запись, намерение публикации, доставку и контракт публичного чтения. На каждой границе заранее фиксируют разрешённое действие, запрещённое действие и evidence — сохранённое подтверждение результата.</p>\n<h2>Сценарий: запись есть, чтения нет</h2>\n<p>Сценарий воспроизводится на одной тестовой заметке. Сначала клиент отправляет команду сохранения и получает пару <code>noteId</code> и <code>revision</code>. Затем тот же клиент выполняет публичный GET. Пустой ответ — факт наблюдения, а не доказательство того, что сломалась очередь.</p>\n<pre><code>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 её не вернул.</code></pre>\n<p>В карточке проверки сохраняют запросы, время, область видимости, фильтры, права доступа и полный ответ чтения. Формулировка «публикация сломалась» уже содержит гипотезу. Формулировка «после ответа 200 GET /public/notes/42 не вернул revision 3» позволяет проверить несколько причин, не изменяя данные.</p>\n<p>Если команда не записала идентификаторы и границы запроса, повтор не добавит знания. Он только изменит состояние, которое нужно расследовать. Поэтому первое решение в кейсе — остановить повтор записи и восстановить evidence исходной операции.</p>\n<h2>Механизм: одна команда, несколько состояний</h2>\n<p>Каноническая запись — источник истины для заметки. Intent — сохранённое намерение выполнить следующий эффект, например публикацию. Relay — обработчик, который переносит intent дальше. Проекция — read model, подготовленная для чтения. Публичный запрос может обращаться к проекции, поисковому индексу или кешу, а не к канонической записи.</p>\n<p>Эти состояния могут обновляться на разных границах. Успешная транзакция записи не доказывает, что intent создан. Созданный intent не доказывает, что relay обработал его. Свежая проекция не доказывает, что конкретный фильтр публичного запроса её вернёт. Диагностика должна сохранять эти различия.</p>\n<p>Для заметки из кейса минимальный набор идентификаторов выглядит так: <code>noteId = 42</code>, <code>revision = 3</code> и ключ намерения <code>intentKey = 42:3:publish</code>. Ключ включает эффект, потому что одна revision может участвовать не только в публикации. Производный ключ допустим лишь при явном доменном правиле: для одной заметки и revision разрешён один publish. Если одинаковая revision может публиковаться дважды с разным смыслом, ключ нужно выдавать на границе операции, а не вычислять из данных.</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 и порядок записи</td><td>Запускать relay</td></tr><tr><td>Есть note, нет intent</td><td>Write → publish</td><td>Проверить правило создания intent для точной revision</td><td>Создавать новую note</td></tr><tr><td>Intent pending</td><td>Relay path</td><td>Найти owner, ключ и статус обработки</td><td>Менять source</td></tr><tr><td>Intent применён, projection старая</td><td>Read model</td><td>Сравнить key, revision и результат consumer</td><td>Делать широкий 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 уже содержит revision 3, повтор relay не объяснит пустой публичный список. В этом случае владелец расследования меняется: нужно проверять контракт чтения.</p>\n<h2>Архитектурное решение как проверяемая запись</h2>\n<p>Запись решения должна отвечать на пять вопросов: какой контекст принят, какой вариант выбран, какие требования обязательны, какие допущения сделаны и когда вариант пересматривают. Фраза «используем outbox» слишком коротка. Outbox — это способ сохранить намерение рядом с изменением, но сама надпись не показывает границу транзакции, владельца обработки или политику повтора.</p>\n<pre><code>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</code></pre>\n<p>Это форма фиксации условий, а не обещание распределённой транзакции или exactly-once delivery. Если note и intent находятся в разных хранилищах, между ними существует окно расхождения. Его нужно назвать, измерить или ограничить, а для восстановления определить отдельную процедуру сверки.</p>\n<p>Нормативные слова в примере относятся к самому решению. RFC 8174 объясняет, когда написанные заглавными буквами MUST, SHOULD и MAY получают нормативный смысл в техническом документе. В чужой системе эти слова ничего не гарантируют, пока команда не закрепила их в контракте, тесте или проверяемом правиле хранения.</p>\n<h2>Канонический write и intent нельзя смешивать</h2>\n<p>Если заметка уже записана, а intent отсутствует, нужно вернуться к границе write → publish. Проверяют commit, обработчик после записи, правило публикации и разрешённый способ восстановления. Новую заметку с другим id не создают: исходная останется без intent, а новая начнёт независимую причинную цепочку.</p>\n<p>Если note и intent пишутся в одной транзакции, команда должна подтвердить это свойствами выбранного хранилища, а не двумя успешными ответами разных API. Если записи разделены, успешность одной операции не является доказательством атомарности другой. Именно это допущение чаще всего скрывает источник необратимой ошибки.</p>\n<pre><code>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}</code></pre>\n<p>При последовательном вызове функция возвращает <code>intent-recorded</code> один раз, а второй вызов — <code>already-recorded</code>. Это делает отрицательный путь видимым. Но <code>Set</code> живёт только в памяти процесса: рестарт, два процесса и гонка между проверкой и записью нарушат гарантию. В рабочей системе уникальность ключа и запись intent должны быть защищены транзакцией или уникальным ограничением хранилища.</p>\n<h2>Pending не означает потерю</h2>\n<p>Pending означает, что разрешённое намерение существует, но подтверждение read effect ещё не найдено. Evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, состояние нельзя назвать pending по интуиции: сначала проверяют, была ли запись intent.</p>\n<p>Пока relay не подтвердил результат, безопасное действие — сохранить source и собрать недостающие данные. Нельзя очищать source, менять revision или отправлять широкий replay «на всякий случай». Такие операции уничтожают исходный контекст и могут повторить побочный эффект для нескольких записей.</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>Повторная доставка допустима, когда известны <code>intentKey</code>, <code>noteId</code>, <code>revision</code>, владелец проекции и результат первой попытки. Relay проверяет ключ до эффекта. Уже записанный ключ означает повтор того же намерения, а не разрешение создать вторую проекцию.</p>\n<p>AWS Builders’ Library описывает ту же границу для идемпотентных API: повтор должен иметь устойчивый идентификатор, а сервер должен отличать повторное обращение от нового намерения. Для части операций AWS связывает запись идентификатора и изменение ресурса атомарной операцией. В нашем кейсе это не готовая реализация, а критерий, с которым сравнивают проектное решение.</p>\n<p>Нельзя считать повтором запрос только потому, что его параметры совпали. Владелец мог дважды намеренно сохранить одинаковый текст. Поэтому ключ должен выражать семантику операции. Если сервер видит тот же ключ с другим payload, он возвращает конфликт, а не молча принимает вторую версию.</p>\n<p>Старая доставка также не должна затирать новую проекцию. Если в projection уже стоит revision 4, событие для revision 3 останавливают и сохраняют конфликт. Идемпотентность — это не подавление ошибок, а повторяемое решение для одного ключа при сохранении несовместимых состояний.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать симптом: запрос, время, noteId, ожидаемую revision, scope, filter, права и ответ public read.</li><li>Проверить запись решения: контекст, вариант, MUST/MUST NOT, допущение о границе и критерий пересмотра.</li><li>Проверить каноническую note по noteId и revision. До этого не создавать замену и не менять source.</li><li>Проверить intent для точного ключа noteId:revision:publish. Если ключа нет, исследовать write → publish, а не очередь.</li><li>Если intent pending, найти owner relay и сохранённое evidence обработки. Source оставить неизменным.</li><li>Если intent применён, сравнить revision projection с revision source и результат consumer.</li><li>Если нужен replay, ограничить его одним intentKey, одной revision и одним известным consumer.</li><li>Если projection свежая, закончить ветку relay и проверить read contract: scope, filter, tenant, права, кеш, формат id и query.</li><li>Записать результат и только затем выполнить обратимое действие на найденной границе.</li></ol>\n<p>Порядок защищает расследование от самоподтверждающейся ошибки. Каждое изменение состояния может скрыть первопричину, поэтому сначала сохраняют доказательства, затем выбирают узкую операцию. После действия повторяют тот же read contract и сравнивают revision, а не только HTTP-статус.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Схема не делает отложенную согласованность мгновенной и не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если public read обязан быть строго синхронным, отложенная проекция через outbox может не соответствовать требованию. Вариант пересматривают, а не маскируют несовпадение дополнительными retry.</p>\n<p>Учебный код не учитывает несколько процессов, конкурентные записи, рестарт, сетевой timeout и повтор после неизвестного результата. Политику хранения ключей выбирают по сроку возможного повтора и риску эффекта. Для реального проекта отдельно проверяют уникальность ключа, TTL, конфликт payload и поведение после частичного commit.</p>\n<p>Разбор готов, когда другой инженер может повторить его без устного контекста. В записи видны исходный симптом, noteId, revision, intentKey, граница причины, evidence, владелец следующего шага и разрешённое действие. Критерий для доставки: две одинаковые доставки одного ключа дают не более одного эффекта, а старая revision не затирает новую projection. Критерий для пустого чтения: после подтверждения свежей projection найдено объяснение в scope, filter, правах, кеше или query.</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>"
}