Симптом полевого разбора: редактор видит новую запись в source, а публичный читатель получает предыдущий title. Цена не только в одной жалобе. Если сразу очистить весь кеш, мы временно скроем след и не узнаем, какой key дал старую проекцию, была ли event задержана и имел ли этот читатель право видеть новую запись. Следующая такая ошибка появится под другим URL и снова будет выглядеть случайной.
Ниже нет настоящего инцидента, user data, cache log или HTTP-запроса. Это controlled fixture с одним synthetic article guide-42. В нём source v1 строит public entry v1, source меняется на v2, event v2 задерживается, а read обязан rebuild-ить v2 до выдачи. Затем source становится private v3, и public read обязан отказаться от ответа ещё до delivery v3. Такая последовательность полезна именно тем, что каждый переход задан и не смешан с инфраструктурным шумом.
Собираем пять фактов до очистки key
При stale-read нельзя начинать с причины «кеш не очистился». Это только гипотеза. Сначала нужен evidence packet из пяти значений: идентификатор source object, его current version, вычисленный read key, version cache entry и факт event. Шестое значение — reader scope или visibility — определяет, вправе ли читатель вообще получить проекцию. Если взять только title из source и title из ответа, вы увидите расхождение, но не сможете отличить старую entry от ключа другой области чтения.
| Факт | Учебное значение | Что отделяет | Нельзя заключить |
|---|---|---|---|
| Source id | guide-42 | какой объект изменял владелец | что все зависимые keys уже найдены |
| Current version | 2 | запись source v2 от cache v1 | что v2 уже доставлена через broker |
| Read key | article:public:guide-42 | публичную проекцию от другого context | что key покрывает tenant, язык или role конкретного проекта |
| Cache version | 1 | наблюдаемую stale entry | что TTL настроен неверно |
| Event | ArticleChanged v2, pending | окно write → delivery → read | что реальный consumer уже получил сообщение |
| Visibility | public, затем private | разницу между stale и недопустимым ответом | что authorization всего приложения проверена |
В реальной системе эти факты могут жить в разных местах. Source version приходит из базы или service API, key вычисляет application, entry видна в cache store, event виден в broker или outbox. Здесь они специально находятся в одном object, потому что мы проверяем логику, а не доступ к стенду. Не надо подменять отсутствие доступа вымышленными ID и timestamps. Если факта нет, честная запись диагностики звучит так: «пока не знаем, какое состояние read сравнил с source».
Воспроизводим задержанное событие
Fixture намеренно не применяет event v2 сразу. После write source v2 cache всё ещё содержит v1. Следующий read сравнивает две versions, пересобирает public projection и возвращает stale-rebuilt. Потом delivery v2 видит entry v2 и отвечает kept-current-entry. Это важная проверка против «очищать по каждому event»: позднее сообщение не должно создавать лишний miss и прятать уже правильную entry.
firstRead: miss-built, version 1
write source: ArticleChanged, version 2
read before event: stale-rebuilt, version 2
deliver v2 event: kept-current-entry
read after event: hit-current, version 2
write visibility: private, version 3
public read: not-visible + evict
Такая строка результата не является таймлайном production. В ней нет миллисекунд, host, account, URL, ответа HTTP или queue offset. Здесь важен порядок: v2 записана до read, event доставлена после read. Если проверяемая среда не может гарантировать, что read увидит source v2, этот конкретный verdict нельзя переносить туда. Тогда задача меняется: описать реплику, stale window и механизм validation выбранного стека, а не выкручивать условие в примере.
# Запускается только модель Map из revision-модуля.
node scripts/upgrade-2021-02.mjs --verify-fixture
# Ожидаемые истинные assertions:
staleReadRebuiltVersionTwo: true
delayedEventDidNotEvictCurrentVersion: true
privateSourceIsNotVisibleBeforeEvent: true
Отделяем stale entry от другого дефекта
Первый вариант: source v2, cache v1, key совпадает, event pending. Это действительно stale entry; действие — rebuild по version guard либо применить targeted invalidation, затем проверить следующий read. Второй вариант: source v2, но key в запросе другой, например отсутствует language или reader scope. Тут очистка правильного public key ничего не даст: читается другой contract. Нужно исправить builder key и добавить case, который различает представления.
Третий вариант: source уже private v3, а cache содержит public v2. Это не «подождём пока event дойдёт». Read не должен возвращать значение, потому что изменилось право показа. В модели он evict-ит entry и выдаёт not-visible до broker. Четвёртый вариант: source, key и cache version совпадают, но пользователь всё равно видит старый текст. Тогда кеш — не доказанная причина. Возможно, view собирает другой field, клиент держит локальное состояние или релиз ещё не получил новую сборку. Следующая проверка должна быть на renderer или delivery, а не на случайную очистку key.
| Наблюдение | Вероятная граница | Безопасная проверка | Следующее действие |
|---|---|---|---|
| source v2, entry v1, один key | event запоздало или entry не проверяет version | запустить controlled stale/read fixture | добавить version guard или targeted invalidation |
| source v2, expected key отсутствует | builder key не включает reader context | вывести key для двух разных projections | исправить contract key и тест на collision |
| source private v3, entry public v2 | visibility проверяется после hit или только consumer-ом | сменить только visibility в fixture | deny + evict до возврата value |
| source v2, entry v2, title старый | не доказано, что читает этот renderer | сверить projection fields без реальных данных | искать другой read layer, не чистить cache вслепую |
| event v2 пришло после rebuild v2 | consumer удаляет по любому событию | проверить entry.version < event.version | оставить current entry и зафиксировать duplicate policy |
Проверяем не только version, но и состав проекции
Версия защищает от старой source записи, но не от случайного поля. В fixture source содержит editorNote, а createPublicProjection() явно возвращает только четыре public field. Assertion проверяет отсутствие editorNote в результатe. Это не полноценная проверка прав пользователя, однако она ловит важный класс ошибок: разработчик добавил поле к source object и сделал spread в cache value, не пересмотрев право публичного чтения.
const current = model.readPublicProjection('guide-42');
current.status; // 'hit-current'
current.projection.sourceVersion; // 2
Object.hasOwn(current.projection, 'editorNote'); // false
// После source v3 с visibility = 'private':
model.readPublicProjection('guide-42').status; // 'not-visible'
Здесь важно не сделать обратную ошибку и не сохранить каждый permission в key автоматически. Key выражает только ту область, которая действительно меняет результат. В учебном public contract достаточно public. Если проект вводит role, locale или tenant, сначала надо показать, как они меняют projection и где являются owner. Иначе cache станет дорогой картой случайных параметров, а утечка всё равно останется в месте, которое никто не назвал.
Что из HTTP помогает, а что не переносится
RFC 7232 описывает validators для HTTP-representation. If-None-Match делает request условным и позволяет origin вернуть 304, если entity-tag совпал. Это может быть частью реального HTTP-read path, но не равно нашему sourceVersion. Entity-tag может описывать выбранное представление, а source version — порядок записи владельца. Смешать их в одной переменной удобно только до первого разного reader scope или renderer.
RFC 7234 также требует invalidation effective request URI при успешном unsafe request, но предупреждает, что другие caches могут остаться с подходящими responses. Поэтому проверка «origin получил POST 200» не доказывает, что браузер, reverse proxy и application cache уже дают одно и то же. Для разрешённой интеграции нужно выбрать один слой, записать его key, validator или purge contract и проверить конкретный response. Этот пакет намеренно до такого шага не доходит.
Нумерованный маршрут разбора
- Зафиксировать symptom одним предложением: какой reader получил какую старую проекцию и почему это дорого. Не писать причину заранее.
- Собрать source id, current version, read key, cached version, event state и visibility. Не очищать key до сохранения этих шести фактов.
- Проверить, что key принадлежит именно этому reader scope и действительно ведёт к наблюдаемой entry.
- Если source version выше cached, воспроизвести write → delayed event → read на контролируемой модели. Проверить, что read не возвращает stale as hit.
- Если visibility изменилась, проверить deny и eviction до delivery event. Право чтения важнее срока жизни entry.
- Если versions совпадают, перенести поиск на renderer, другой cache layer или delivery. Не приписывать кешу любой старый текст.
- После фактов выбрать один настоящий integration test на разрешённом store или HTTP-route и зафиксировать его отдельные гарантии.
Граница разбора и следующий шаг
Этот разбор не подключает broker, Redis, CDN, database, browser cache или framework. У него нет реальных event retries, duplicate delivery, latency, user data и статистики hit-rate. Fixture проверяет только детерминированный порядок одного object в памяти. Поэтому «PASS» здесь означает, что contract текста не возвращает v1 после известной v2 и не показывает private v3 публичному reader. Он не означает, что настоящий сервис уже обеспечивает такое свойство.
После этого разбора остаётся короткий следующий шаг: выбрать один реальный projection и собрать тот же evidence packet без чувствительных данных. Если source version, key, cache version и event нельзя увидеть в одной тестовой истории, сначала добавьте эту наблюдаемость. Тогда следующая статья будет опираться не на яркий purge, а на проверяемый факт, почему конкретный читатель получил именно это представление.
Проверяемые источники
- RFC 7234 — HTTP/1.1 Caching, июнь 2014 — исторический стандарт, действовавший в феврале 2021 года: задаёт ключи, reuse, validation и invalidation HTTP-ответов; не является API прикладного cache store
- RFC 7232 — HTTP/1.1 Conditional Requests, июнь 2014 — описывает entity-tag, If-None-Match и 304 как HTTP-механизм проверки представления; эти поля не заменяют версию прикладной записи