From 496d61a8cbca79a812c7ad9a53f2463182755e42 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 21:23:00 +0300 Subject: [PATCH] Editorial: refine cache invalidation article 247 --- editorial/agent-rewrites/247.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/247.json b/editorial/agent-rewrites/247.json index b707890..32bbd08 100644 --- a/editorial/agent-rewrites/247.json +++ b/editorial/agent-rewrites/247.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-02-field-cache-invalidation", "title": "Инвалидация кеша: как доказать, что читатель получил старую проекцию", "excerpt": "Пользователь видит старое значение после записи в source. Разбираем key, version, delayed event и visibility, чтобы выбрать точечное действие вместо очистки всего кеша.", - "contentHtml": "

Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.

\n

Кеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.

\n

Тезис: инвалидируйте состояние, а не симптом

\n

Для одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.

\n

Эта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.

\n

Механизм старого чтения

\n

Пусть source хранит запись guide-42. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом article:public:guide-42. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие ArticleChanged(2) может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.

\n

Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.

\n
function applyInvalidation(cache, key, eventVersion) {\n  const entry = cache.get(key);\n  if (!entry) return 'nothing-to-remove';\n  if (entry.sourceVersion < 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 || 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}
\n

Код показывает только порядок решений. Map не заменяет Redis или транзакционное хранилище. В реальном read source может быть дорогим или отставать на реплике. Тогда нужно отдельно описать, откуда приходит version, какой stale window допустим и какую гарантию получает читатель. Нельзя переносить этот фрагмент в production без проверки атомарности записи, прав и конкурирующих обновлений.

\n

Соберите доказательства до purge

\n

Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
source v2, entry v1, key совпадаетСобытие задержалось или read не проверяет versionСравнить версии до и после controlled readДобавить version guard или точечную инвалидацию
source v2, но key отличаетсяВ key пропущен язык, tenant или reader scopeВывести keys для двух проекций одного idИсправить builder и проверить отсутствие collision
source private v3, entry public v2Visibility проверяется после cache hitИзменить только visibility и повторить public readЗапретить ответ и удалить entry до delivery события
source v2, entry v2, текст всё ещё старыйОшибка renderer, клиента или другого cache layerСверить projection fields и цепочку ответаИскать следующий слой, не очищать этот key вслепую
Позднее событие удаляет v2Consumer удаляет entry при любом событииПроверить условие entry.sourceVersion < event.versionОставить entry для равной version
\n
\"Схема
Один старый текст не доказывает одну причину. Сначала соберите состояние, затем выберите ветку действия.
\n

Задержанное событие и отрицательный путь

\n

Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.

\n
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
\n

Отрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.

\n

Версия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это id, title и summary. Поле editorNote не должно попасть в entry даже при правильной version.

\n

HTTP-кеш — соседний слой

\n

HTTP validator и прикладная version решают разные задачи. ETag описывает выбранное HTTP-представление. If-None-Match позволяет запросу проверить его и получить 304. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.

\n

Успешный unsafe HTTP-запрос инвалидирует target URI в том cache, который его обработал, но это не очищает автоматически браузерный, reverse-proxy и прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ. Статус 200 от origin сам по себе не доказывает свежесть всех downstream-слоёв.

\n

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

\n
  1. Зафиксируйте симптом: какой reader получил какую старую проекцию и какова цена ошибки.
  2. Сохраните source id, current version, read key, cache version, event state и visibility до очистки.
  3. Проверьте, что key принадлежит нужному reader scope и включает каждый параметр, меняющий результат.
  4. Сравните source version и cache version на контролируемом чтении. Не называйте cache hit корректным, пока версии не сопоставлены.
  5. Для старой entry выполните rebuild или targeted invalidation. Не удаляйте весь namespace без причины.
  6. Для изменения visibility проверьте deny и eviction до доставки события.
  7. Проверьте delayed event: равная version не должна удалять актуальную entry; более новая version должна удалить старую.
  8. Если версии совпадают, перенесите диагностику на renderer, клиент, HTTP или другой cache layer.
\n

Ограничения и критерий готовности

\n

Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.

\n

Решение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: 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.

\n

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

\n" + "contentHtml": "

Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.

\n

Кеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.

\n

Тезис: инвалидируйте состояние, а не симптом

\n

Для одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.

\n

Эта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.

\n

Механизм старого чтения

\n

Пусть source хранит запись guide-42. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом article:public:guide-42. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие ArticleChanged(2) может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.

\n

Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.

\n
function applyInvalidation(cache, key, eventVersion) {\n  const entry = cache.get(key);\n  if (!entry) return 'nothing-to-remove';\n  if (entry.sourceVersion < 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 && entry.sourceVersion > 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}
\n

В этом фрагменте source.get должен читать авторитетную запись, а не случайную реплику. Если read идёт с реплики, результат с current.version, меньшей уже сохранённой cache version, — сигнал отставания: его нельзя записывать поверх более новой entry, нужно повторить чтение с требуемой консистентностью. Возврат source-read-behind-cache делает такую ошибку видимой, вместо того чтобы принять её за обычный hit.

\n

Код показывает порядок решений, но не атомарность. Map не заменяет Redis или транзакционное хранилище. В реальном read отдельно опишите источник version, допустимое окно stale-read и гарантию для читателя. Запись rebuilt projection должна быть условной: конкурентный writer не должен позволить результату v1 перезаписать уже сохранённый v2. Нельзя переносить этот фрагмент в production без проверки атомарности, прав и конкурирующих обновлений.

\n
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
\n

Запустите оба блока подряд в Node.js: второй использует readPublic и applyInvalidation из первого. source и cache — две независимые Map. Значения version, поле visibility и состав public projection — проектные; важен не текст Old/New, а наблюдаемые статусы и переходы версий.

\n

Соберите доказательства до purge

\n

Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
source v2, entry v1, key совпадаетСобытие задержалось или read не проверяет versionСравнить версии до и после controlled readДобавить version guard или точечную инвалидацию
source v2, но key отличаетсяВ key пропущен язык, tenant или reader scopeВывести keys для двух проекций одного idИсправить builder и проверить отсутствие collision
source private v3, entry public v2Visibility проверяется после cache hitИзменить только visibility и повторить public readЗапретить ответ и удалить entry до delivery события
source v2, entry v2, текст всё ещё старыйОшибка renderer, клиента или другого cache layerСверить projection fields и цепочку ответаИскать следующий слой, не очищать этот key вслепую
read source v1, entry v2Реплика отстаёт или key ведёт к чужой проекцииПовторить authoritative read и сверить scopeНе перезаписывать v2, зафиксировать anomaly и повторить чтение
Позднее событие удаляет v2Consumer удаляет entry при любом событииПроверить условие entry.sourceVersion < event.versionОставить entry для равной version
\n
\"Схема
Один старый текст не доказывает одну причину. Сначала соберите состояние, затем выберите ветку действия.
\n

Задержанное событие и отрицательный путь

\n

Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.

\n
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
\n

Отрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.

\n

Версия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это id, title и summary. Поле editorNote не должно попасть в entry даже при правильной version.

\n

HTTP-кеш — соседний слой

\n

HTTP validator и прикладная version решают разные задачи. ETag описывает выбранное HTTP-представление. If-None-Match позволяет запросу проверить его и получить 304. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.

\n

Если HTTP-кеш получил ответ со статусом 2xx или 3xx на unsafe-запрос, RFC 9111 требует инвалидировать target URI в этом кеше. Он может инвалидировать и другие URI из Location или Content-Location, но только при совпадающем origin. Это правило относится к HTTP-кешу, который получил ответ, и не очищает автоматически браузерный, reverse-proxy или прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ: статус 200 от origin сам по себе не доказывает свежесть downstream-слоёв.

\n

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

\n
  1. Зафиксируйте симптом: какой reader получил какую старую проекцию и какова цена ошибки.
  2. Сохраните source id, current version, read key, cache version, event state и visibility до очистки.
  3. Проверьте, что key принадлежит нужному reader scope и включает каждый параметр, меняющий результат.
  4. Сравните source version и cache version на контролируемом чтении. Не называйте cache hit корректным, пока версии не сопоставлены.
  5. Для старой entry выполните rebuild или targeted invalidation. Не удаляйте весь namespace без причины.
  6. Для изменения visibility проверьте deny и eviction до доставки события.
  7. Проверьте delayed event: равная version не должна удалять актуальную entry; более новая version должна удалить старую.
  8. Если версии совпадают, перенесите диагностику на renderer, клиент, HTTP или другой cache layer.
\n

Ограничения и критерий готовности

\n

Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.

\n

Решение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: 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.

\n

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

\n" }