Симптом механической ошибки звучит так: source уже записал v2, но cache entry v1 ещё выглядит валидной, потому что её TTL не истёк. Цена — выдать читателю старую проекцию именно после известного изменения. Такая ошибка коварна: cache работает быстро, лог event может быть зелёным, а спорный ответ появляется только в узком порядке write, delayed invalidation и read.
Причина обычно не в самом слове invalidation. У команды нет единого ответа на четыре вопроса: кто владеет source, что образует key, какая версия входит в событие и какое условие разрешает hit. В учебном контракте один source owner обновляет запись, одно событие сообщает её version, а read сравнивает cached version с source version. Это не замена Redis, CDN или HTTP caching: это минимальная модель, в которой можно разобрать порядок без реального трафика.
Историческая граница HTTP-кеширования
В феврале 2021 для HTTP применялся RFC 7234. Он определял primary cache key как method и target URI и допускал secondary keys для selecting header fields. При неошибочном ответе на unsafe request cache должен инвалидировать effective request URI; invalidation означает удалить связанные stored responses либо пометить их требующими validation. Но тот же документ прямо ограничивает обещание: state-changing request может пройти через часть кешей, а подходящие ответы могут остаться в других.
Эта норма не превращает прикладной event в HTTP request и не даёт нам право назвать любую Map HTTP cache. Она даёт полезный язык: reuse разрешён не просто потому, что есть значение, а при соблюдении ключа, свежести или validation. RFC 7232 дополняет его validators: If-None-Match позволяет проверять представление через entity-tag и получать 304 Not Modified. В нашем коде sourceVersion — не ETag и не header. Это отдельная версия учебного source record, выбранная для детерминированной модели.
Version связывает три разных состояния
Одна цифра нужна в трёх местах. В source она говорит, какую запись создал владелец. В event она говорит, на какую запись ссылается invalidation. В cache entry она говорит, из какой source version собрана проекция. Если хотя бы одно место живёт отдельной нумерацией, уже нельзя объяснить, действительно ли entry v1 устарела для event v2. Timestamp не всегда помогает: у него может быть разная точность и источник, а версия выражает порядок именно одного owner.
| Source | Cache entry | Event v2 | Что вправе вернуть read |
|---|---|---|---|
| v1 public | v1 public | нет | hit-current: версия и visibility совпадают |
| v2 public | v1 public | ещё не применён | stale-rebuilt: вернуть rebuilt v2, не v1 |
| v2 public | нет | применён | miss-built: собрать public projection v2 |
| v2 public | v2 public | пришёл поздно | hit-current: event v2 не удаляет entry v2 |
| v3 private | v2 public | ещё не применён | not-visible: evict v2 и ничего не показать |
Последняя строка не является факультативной. Если visibility меняется, прежняя публичная entry становится не просто старой, а недопустимой для читателя. Read сначала смотрит на source record и только затем считает entry hit. Так он не ждёт таймер и не надеется на delivery сообщения, когда право показа уже исчезло. В системе с отдельной auth boundary реализация будет другой, но порядок вопроса остаётся: проверка права должна предшествовать возврату cache value.
Invalidation удаляет только действительно старую entry
Наивный consumer удаляет key для каждого пришедшего event. Это создаёт другой дефект: event v2 задержалось, read уже успел построить v2, а позднее сообщение стирает current value. Следующий read будет лишним miss. Хуже, если у consumer есть несколько delivery попыток и нет наблюдаемого правила. В учебном обработчике event удаляет entry только при entry.sourceVersion < event.sourceVersion. Равная version означает, что cache уже current относительно этого события.
function applyInvalidation(event) {
const entry = cache.get(event.cacheKey);
if (!entry) return { action: 'no-entry' };
if (entry.sourceVersion < event.sourceVersion) {
cache.delete(event.cacheKey);
return { action: 'evicted-older-entry' };
}
return { action: 'kept-current-entry' };
}
Это правило не делает event order полностью безопасным для всех систем. Например, event может нести не тот key, source owner может выдавать версии неатомарно, а два разных projection key могут зависеть от одной записи. Для такой схемы нужен расширенный dependency contract, а не более смелый знак сравнения. Но для одного owner и одного key правило полезно: оно различает «очистить старое» и «снести уже построенное текущее».
Read-path закрывает окно до delivery
Теперь важный контрпример. Source записал v2. Event существует в памяти, но applyInvalidation ещё не вызван. Cache по key всё ещё содержит v1. Если read делает только cache.get(key), он выдаёт stale. Если read сравнивает versions, он видит 1 !== 2, строит новую public projection и заменяет entry. Именно это fixture называет stale-rebuilt. Результат не равен HTTP validation и не доказывает, что реальный source storage доступен так же быстро; он показывает явный выбор нашего контракта.
function readPublicProjection(id) {
const record = source.get(id);
const key = publicProjectionKey(id);
const entry = cache.get(key);
if (!record || record.visibility !== 'public') {
cache.delete(key);
return { status: 'not-visible' };
}
if (entry && entry.sourceVersion === record.version) {
return { status: 'hit-current', projection: entry.projection };
}
const projection = createPublicProjection(record);
cache.set(key, { sourceVersion: record.version, projection });
return { status: entry ? 'stale-rebuilt' : 'miss-built', projection };
}
Равенство version ещё не достаточно без visibility. Если source v3 стал private, entry v2 может совпадать с последней известной cache version, но уже нарушает правило выдачи. Поэтому пример проверяет record.visibility до cache hit, удаляет public key и возвращает not-visible. Полезная мелочь: public projection строится функцией whitelist, а не copy всего record. Тогда вы не надеетесь, что новый внутренний field случайно не попадёт в сериализацию следующего месяца.
TTL — дополнительный срок, а не доказательство current
TTL полезен, когда нужно ограничить рост памяти или допустимое время без обращения к source. Он делает entry временной, но не сообщает, что произошло после её записи. Если запись source v2 уже успешна, пяти минут freshness для v1 недостаточно, чтобы назвать ответ правильным. В HTTP cache freshness и validation регулируются RFC; в приложении можно выбрать TTL, version check, explicit invalidation либо иной protocol. Нельзя взять имя одной директивы и объявить, что она решит все слои одинаково.
При этом version check тоже имеет цену. В нашей Map read видит source напрямую; реальное чтение source может быть дорого, иметь replica lag или быть запрещено на public path. Тогда нельзя молча сохранить этот алгоритм. Нужно записать где хранится version, какую гарантию получает read, как распространяется invalidation и какой stale window бизнес допускает. Автор М4 в этом месте уже видит границу между data owner и projection, но не выдумывает согласованность там, где её не проверял.
Fixture фиксирует порядок без настоящего broker
Все идентификаторы, заголовки, версии, события и результаты ниже учебные. Модель работает только с Map в памяти: она не подключает Redis, CDN, broker, framework, HTTP-клиент или production traffic.
# Запускается только модель Map из revision-модуля.
node scripts/upgrade-2021-02.mjs --verify-fixture
# Ожидаемые истинные assertions:
staleReadRebuiltVersionTwo: true
delayedEventDidNotEvictCurrentVersion: true
privateSourceIsNotVisibleBeforeEvent: true
Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2. Projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object.
Fixture не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.
Маршрут проектирования механизма
- Для одного read path выписать source owner, reader scope и допустимую проекцию. Если это не один contract, не пытаться решить его одним key.
- Выбрать монотонную version у source owner и определить, когда именно она становится следующей: после успешного write, а не до него.
- Включить id и version в event. Key можно не передавать только если он строится детерминированно из этих данных и это зафиксировано.
- В consumer удалять entry только тогда, когда её sourceVersion меньше version события. Равная version уже current для этого event.
- На read проверять visibility до выдачи и сравнивать versions там, где source или его version действительно доступны по выбранной гарантии.
- Отдельно описать TTL, retries, duplicate events, multi-key dependency и подтверждение доставки для настоящего выбранного store. Не подменять этот шаг fixture.
Ограничения и следующий проверяемый шаг
Учебный model не делает distributed transaction между source и event. Он не утверждает, что запись в базу и отправка в broker происходят атомарно, не измеряет задержку и не заменяет outbox, retry или reconciliation. Он также не моделирует multi-region, несколько reader role, pagination или key invalidation по тегам. Эти темы требуют отдельной статьи с конкретным storage и его документацией.
Зато у механизма есть проверяемый результат: любой cache hit можно объяснить парой key + sourceVersion и текущим правом читателя. Если в логе или fixture нельзя показать эту пару, не надо спорить о TTL. Сначала зафиксируйте контракт одного ключа, добавьте stale-before-event сценарий и проверьте, что late event не разрушает уже current projection.
Проверяемые источники
- 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-механизм проверки представления; эти поля не заменяют версию прикладной записи