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

8 lines
18 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": 247,
"slug": "editorial-2021-02-field-cache-invalidation",
"title": "Инвалидация кеша: как доказать, что читатель получил старую проекцию",
"excerpt": "Пользователь видит старое значение после записи в source. Разбираем key, version, delayed event и visibility, чтобы выбрать точечное действие вместо очистки всего кеша.",
"contentHtml": "<p>Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.</p>\n<p>Кеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.</p>\n<h2>Тезис: инвалидируйте состояние, а не симптом</h2>\n<p>Для одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.</p>\n<p>Эта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.</p>\n<h2>Механизм старого чтения</h2>\n<p>Пусть source хранит запись <code>guide-42</code>. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом <code>article:public:guide-42</code>. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие <code>ArticleChanged(2)</code> может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.</p>\n<p>Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.</p>\n<pre><code>function applyInvalidation(cache, key, eventVersion) {\n const entry = cache.get(key);\n if (!entry) return 'nothing-to-remove';\n if (entry.sourceVersion &lt; eventVersion) {\n cache.delete(key);\n return 'removed-stale-entry';\n }\n return 'kept-current-entry';\n}\n\nfunction readPublic(source, cache, id) {\n const current = source.get(id);\n const key = `article:public:${id}`;\n const entry = cache.get(key);\n\n if (current.visibility !== 'public') {\n cache.delete(key);\n return { status: 'not-visible' };\n }\n if (entry &amp;&amp; entry.sourceVersion &gt; current.version) {\n return { status: 'source-read-behind-cache' };\n }\n if (!entry || entry.sourceVersion !== current.version) {\n const projection = {\n id: current.id,\n title: current.title,\n summary: current.summary,\n sourceVersion: current.version,\n };\n cache.set(key, projection);\n return { status: 'rebuilt', projection };\n }\n return { status: 'hit-current', projection: entry };\n}</code></pre>\n<p>В этом фрагменте <code>source.get</code> должен читать авторитетную запись, а не случайную реплику. Если read идёт с реплики, результат с <code>current.version</code>, меньшей уже сохранённой cache version, — сигнал отставания: его нельзя записывать поверх более новой entry, нужно повторить чтение с требуемой консистентностью. Возврат <code>source-read-behind-cache</code> делает такую ошибку видимой, вместо того чтобы принять её за обычный hit.</p>\n<p>Код показывает порядок решений, но не атомарность. <code>Map</code> не заменяет Redis или транзакционное хранилище. В реальном read отдельно опишите источник version, допустимое окно stale-read и гарантию для читателя. Запись rebuilt projection должна быть условной: конкурентный writer не должен позволить результату v1 перезаписать уже сохранённый v2. Нельзя переносить этот фрагмент в production без проверки атомарности, прав и конкурирующих обновлений.</p>\n<pre><code>const source = new Map([\n ['guide-42', { id: 'guide-42', title: 'Old', summary: 'v1', version: 1, visibility: 'public' }],\n]);\nconst cache = new Map([\n ['article:public:guide-42', { id: 'guide-42', title: 'Old', summary: 'v1', sourceVersion: 1 }],\n]);\n\nsource.set('guide-42', {\n id: 'guide-42', title: 'New', summary: 'v2', version: 2, visibility: 'public',\n});\nconsole.log(readPublic(source, cache, 'guide-42').status); // rebuilt\nconsole.log(cache.get('article:public:guide-42').sourceVersion); // 2\nconsole.log(applyInvalidation(cache, 'article:public:guide-42', 2)); // kept-current-entry\n\nsource.set('guide-42', {\n id: 'guide-42', title: 'New', summary: 'v2', version: 2, visibility: 'private',\n});\nconsole.log(readPublic(source, cache, 'guide-42').status); // not-visible</code></pre>\n<p>Запустите оба блока подряд в Node.js: второй использует <code>readPublic</code> и <code>applyInvalidation</code> из первого. <code>source</code> и <code>cache</code> — две независимые <code>Map</code>. Значения <code>version</code>, поле <code>visibility</code> и состав public projection — проектные; важен не текст <code>Old</code>/<code>New</code>, а наблюдаемые статусы и переходы версий.</p>\n<h2>Соберите доказательства до purge</h2>\n<p>Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.</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>source v2, entry v1, key совпадает</td><td>Событие задержалось или read не проверяет version</td><td>Сравнить версии до и после controlled read</td><td>Добавить version guard или точечную инвалидацию</td></tr><tr><td>source v2, но key отличается</td><td>В key пропущен язык, tenant или reader scope</td><td>Вывести keys для двух проекций одного id</td><td>Исправить builder и проверить отсутствие collision</td></tr><tr><td>source private v3, entry public v2</td><td>Visibility проверяется после cache hit</td><td>Изменить только visibility и повторить public read</td><td>Запретить ответ и удалить entry до delivery события</td></tr><tr><td>source v2, entry v2, текст всё ещё старый</td><td>Ошибка renderer, клиента или другого cache layer</td><td>Сверить projection fields и цепочку ответа</td><td>Искать следующий слой, не очищать этот key вслепую</td></tr><tr><td>read source v1, entry v2</td><td>Реплика отстаёт или key ведёт к чужой проекции</td><td>Повторить authoritative read и сверить scope</td><td>Не перезаписывать v2, зафиксировать anomaly и повторить чтение</td></tr><tr><td>Позднее событие удаляет v2</td><td>Consumer удаляет entry при любом событии</td><td>Проверить условие <code>entry.sourceVersion &lt; event.version</code></td><td>Оставить entry для равной version</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2021/cache-diagnosis-2021.svg\" alt=\"Схема диагностики старой кешированной проекции: source version, public key, cache version, event и visibility приводят к разным действиям\" loading=\"lazy\" /><figcaption>Один старый текст не доказывает одну причину. Сначала соберите состояние, затем выберите ветку действия.</figcaption></figure>\n<h2>Задержанное событие и отрицательный путь</h2>\n<p>Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.</p>\n<pre><code>build v1 cache: v1\nwrite source v2 event: pending, cache: v1\nread before delivery result: rebuilt v2\ndeliver ArticleChanged2 result: current entry kept\nread after delivery result: hit v2\nwrite visibility private result: deny + evict</code></pre>\n<p>Отрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.</p>\n<p>Версия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это <code>id</code>, <code>title</code> и <code>summary</code>. Поле <code>editorNote</code> не должно попасть в entry даже при правильной version.</p>\n<h2>HTTP-кеш — соседний слой</h2>\n<p>HTTP validator и прикладная version решают разные задачи. <code>ETag</code> описывает выбранное HTTP-представление. <code>If-None-Match</code> позволяет запросу проверить его и получить <code>304</code>. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.</p>\n<p>Если HTTP-кеш получил ответ со статусом <code>2xx</code> или <code>3xx</code> на unsafe-запрос, RFC 9111 требует инвалидировать target URI в этом кеше. Он может инвалидировать и другие URI из <code>Location</code> или <code>Content-Location</code>, но только при совпадающем origin. Это правило относится к HTTP-кешу, который получил ответ, и не очищает автоматически браузерный, reverse-proxy или прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ: статус <code>200</code> от origin сам по себе не доказывает свежесть downstream-слоёв.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте симптом: какой reader получил какую старую проекцию и какова цена ошибки.</li><li>Сохраните source id, current version, read key, cache version, event state и visibility до очистки.</li><li>Проверьте, что key принадлежит нужному reader scope и включает каждый параметр, меняющий результат.</li><li>Сравните source version и cache version на контролируемом чтении. Не называйте cache hit корректным, пока версии не сопоставлены.</li><li>Для старой entry выполните rebuild или targeted invalidation. Не удаляйте весь namespace без причины.</li><li>Для изменения visibility проверьте deny и eviction до доставки события.</li><li>Проверьте delayed event: равная version не должна удалять актуальную entry; более новая version должна удалить старую.</li><li>Если версии совпадают, перенесите диагностику на renderer, клиент, HTTP или другой cache layer.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.</p>\n<p>Решение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: source id, source version, key, cache version, event state и reader visibility. После записи v2 read не возвращает v1 как current hit. Позднее событие v2 сохраняет уже построенную v2. После переключения в private public read не возвращает значение и удаляет public entry. Если хотя бы одно условие нельзя доказать логом или тестом, сначала добавьте наблюдаемость, а не увеличивайте TTL и не включайте глобальный purge.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9111.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9111 — HTTP Caching</a> — действующий стандарт IETF о freshness, validation и invalidation HTTP-ответов; он заменяет RFC 7234 и не задаёт контракт прикладного cache store.</li><li><a href=\"https://redis.io/docs/latest/develop/data-types/streams/\" target=\"_blank\" rel=\"noopener noreferrer\">Redis Streams</a> — официальная документация Redis о append-only stream, XADD, чтении и consumer groups; она описывает транспорт событий, но не гарантирует атомарность source и cache.</li></ul>"
}