DarkRiDDeR16 мин

Разбор stale-read: как найти старую проекцию без догадок

КешированиеОтладкаBackend

Симптом полевого разбора: редактор видит новую запись в 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 от ключа другой области чтения.

Минимальный evidence packet для одного stale-read
ФактУчебное значениеЧто отделяетНельзя заключить
Source idguide-42какой объект изменял владелецчто все зависимые keys уже найдены
Current version2запись source v2 от cache v1что v2 уже доставлена через broker
Read keyarticle:public:guide-42публичную проекцию от другого contextчто key покрывает tenant, язык или role конкретного проекта
Cache version1наблюдаемую stale entryчто TTL настроен неверно
EventArticleChanged v2, pendingокно write → delivery → readчто реальный consumer уже получил сообщение
Visibilitypublic, затем privateразницу между stale и недопустимым ответомчто authorization всего приложения проверена

В реальной системе эти факты могут жить в разных местах. Source version приходит из базы или service API, key вычисляет application, entry видна в cache store, event виден в broker или outbox. Здесь они специально находятся в одном object, потому что мы проверяем логику, а не доступ к стенду. Не надо подменять отсутствие доступа вымышленными ID и timestamps. Если факта нет, честная запись диагностики звучит так: «пока не знаем, какое состояние read сравнил с source».

Вертикальная схема диагностики stale-read: от симптома старой карточки собираются source version, public key, cache version, event state и visibility; затем ветки ведут к rebuild stale entry, исправлению key, отказу private reader или проверке renderer вне кеша
Схема не назначает виновника по одному старому title. Она сначала отделяет four conditions, которые требуют разных действий.

Воспроизводим задержанное событие

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, один keyevent запоздало или 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 v2visibility проверяется после hit или только consumer-омсменить только visibility в fixturedeny + evict до возврата value
source v2, entry v2, title старыйне доказано, что читает этот rendererсверить projection fields без реальных данныхискать другой read layer, не чистить cache вслепую
event v2 пришло после rebuild v2consumer удаляет по любому событиюпроверить 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. Этот пакет намеренно до такого шага не доходит.

Нумерованный маршрут разбора

  1. Зафиксировать symptom одним предложением: какой reader получил какую старую проекцию и почему это дорого. Не писать причину заранее.
  2. Собрать source id, current version, read key, cached version, event state и visibility. Не очищать key до сохранения этих шести фактов.
  3. Проверить, что key принадлежит именно этому reader scope и действительно ведёт к наблюдаемой entry.
  4. Если source version выше cached, воспроизвести write → delayed event → read на контролируемой модели. Проверить, что read не возвращает stale as hit.
  5. Если visibility изменилась, проверить deny и eviction до delivery event. Право чтения важнее срока жизни entry.
  6. Если versions совпадают, перенести поиск на renderer, другой cache layer или delivery. Не приписывать кешу любой старый текст.
  7. После фактов выбрать один настоящий 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-механизм проверки представления; эти поля не заменяют версию прикладной записи