revise February 2021 cache articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 12:16:22 +03:00
parent 323d80b188
commit e67b416a81
7 changed files with 1048 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 109 из 358 созданных материалов. Остальные 249 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 112 из 358 созданных материалов. Остальные 246 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+165
View File
@@ -0,0 +1,165 @@
# Автономное тройное ревью П36 · февраль 2021 · «Инвалидация кеша»
Статус: **принят независимым редактором в выпусковой набор**. В module ровно
три revision для стабильных slug:
- <code>editorial-2021-02-practice-cache-invalidation</code>;
- <code>editorial-2021-02-mechanism-cache-invalidation</code>;
- <code>editorial-2021-02-field-cache-invalidation</code>.
Revision не содержат <code>date</code> и <code>author</code>, не подключают
registry и не переписывают базовый архив. В этой авторской партии не менялись
<code>articles.json</code>, README, очередь, редакционный стандарт, package
config, Git и чужие незакоммиченные файлы.
## Проход 1. Факты, историческая рамка и техника — пройдено
| Утверждение или решение | Первичный / официальный источник | Зафиксированная граница |
| --- | --- | --- |
| В феврале 2021 RFC 7234 задавал HTTP cache key через method и target URI, а при content negotiation допускал selecting header fields | [RFC 7234, section 2 and 4.1](https://www.rfc-editor.org/rfc/rfc7234) | Текст использует это как дисциплину для key, но не называет <code>article:public:guide-42</code> HTTP key или API конкретного store. |
| После неошибочного ответа на unsafe request HTTP cache должен инвалидировать effective request URI; invalidation — удаление либо обязательная validation stored response | [RFC 7234, section 4.4](https://www.rfc-editor.org/rfc/rfc7234) | Статьи отдельно называют предел: запрос может пройти не через все caches, поэтому event не доказывает немедленную очистку каждого слоя. |
| <code>If-None-Match</code> делает request условным и для GET/HEAD может дать <code>304 Not Modified</code> при совпадении entity-tag | [RFC 7232, section 3.2](https://www.rfc-editor.org/rfc/rfc7232) | <code>sourceVersion</code> модели прямо отделена от ETag и HTTP header: это версия учебной записи одного owner. |
| HTTP freshness, validation и прикладная versioned invalidation отвечают на разные вопросы | [RFC 7234, sections 4.2–4.4](https://www.rfc-editor.org/rfc/rfc7234), [RFC 7232, sections 2.3 and 3.2](https://www.rfc-editor.org/rfc/rfc7232) | TTL не выдан за доказательство current source version; ни одна статья не обещает универсальную реализацию для Redis, CDN или framework. |
Во время независимой интеграционной вычитки смешанная буква в английском
глаголе осталась в таблице этого review; она исправлена на русское
«инвалидировать». Основной текст статьи уже разделял HTTP invalidation и
прикладной versioned contract.
### Техническая граница fixture
<code>runCacheInvalidationFixture()</code> создаёт один source object
<code>guide-42</code> и три последовательные версии только в <code>Map</code>:
1. public v1 строит <code>article:public:guide-42</code>;
2. public v2 записывается, но <code>ArticleChanged v2</code> намеренно не
применяется до следующего read;
3. этот read видит cached v1, сравнивает её с source v2 и возвращает rebuilt
public v2;
4. позднее event v2 оставляет уже current entry v2;
5. private v3 делает public read <code>not-visible</code> и удаляет old entry
ещё до delivery v3.
Проверены восемь assertions: initial v1 build, stale rebuild v2, сохранение
current entry поздним event, v2 hit, отсутствие <code>editorNote</code> в
public projection, deny private source до event, отсутствие public entry для
private event и использование только учебных объектов.
Fixture не открывает сеть, не вызывает broker, Redis, CDN, database, HTTP
stack или framework. Она не доказывает atomic write/event, retry, delivery,
cache eviction или production consistency. Это намеренное ограничение
соответствует формулировке статей.
Вердикт прохода: **пройден**. Нормы HTTP описаны с исторической датой, а
учебный contract не выдан за работу реальной инфраструктуры.
## Проход 2. Редактура, глубина и голос М4 — пройдено
| Revision | Симптом и цена в первых двух абзацах | Главный вопрос | Объём основного текста |
| --- | --- | --- | --- |
| Практика | Source уже обновлён, а читатель видит старую карточку; цена — показать не ту публичную проекцию или не вовремя скрыть материал | Как определить owner, public key, version, event и разрешённый hit | **8 882** знака body |
| Механизм | Source v2 известна, а cache v1 ещё не истекла по TTL; цена — отдать stale value после известного write | Почему version связывает source, event и entry, а TTL не заменяет этот contract | **9 903** знака body |
| Полевой разбор | Редактор видит новое значение, reader — старое; цена — очистить key вслепую и потерять evidence | Как отделить stale entry от wrong key, private source и другого read layer | **10 053** знака body |
- Отдельный ручной подсчёт после исключения code, figure и table дал
**7 321 / 8 239 / 7 492** знака чистой прозы. Значит, нижняя граница
выдержана не за счёт фрагментов кода и подписей к схемам.
- Во всех трёх материалах первые два абзаца дают симптом и цену, затем
держат порядок «симптом → причина → проверка → действие».
- У каждой revision больше пяти смысловых разделов, table с
<code>caption</code>/<code>thead</code>, figure с самостоятельными
<code>alt</code>/<code>figcaption</code>, минимум два технических примера,
нумерованный маршрут и две ссылки на RFC.
- Голос М4 / февраля 2021 прагматичен: owner, key, version, event, projection
и visibility всегда привязаны к конкретной операции. Автор уже связывает
данные с инфраструктурной границей, но не изображает себя владельцем
распределённой cache-платформы.
- Отдельно вычитаны анахронизмы и ложный опыт. В статьях нет выдуманного CDN,
cache hit-rate, данных пользователей, production-инцидента, real broker
delivery или универсального framework API.
- Каждый title и excerpt обещает ограниченный результат учебной модели, а не
«полное решение инвалидации» для любого стека.
Вердикт прохода: **пройден**. Объём и плотность соответствуют редакционному
стандарту; текст развивает автора от delivery и данных к одному проверяемому
contract чтения без техлидской позы 2027 года.
## Проход 3. Визуал, fixture и preflight — пройдено в границах пакета
- <code>cache-key-lifecycle-2021.svg</code> показывает полный порядок source
v2 → public key → delayed event → version guard → rebuilt public v2. В
первом рендере текст карточек соприкасался со стрелками; карточки были
увеличены, схема отрендерена и просмотрена повторно.
- <code>cache-consistency-matrix-2021.svg</code> сопоставляет пять состояний
source, cache и event с допустимым решением read, включая delayed v2 и
private v3.
- <code>cache-diagnosis-2021.svg</code> ведёт от stale title к evidence
packet и отделяет stale entry, wrong key, private source и renderer вне
кеша.
- У всех SVG есть <code>title</code>, <code>desc</code>, <code>role="img"</code>,
вертикальный viewBox, контрастные карточки и короткие строки. В них нет
JavaScript, <code>foreignObject</code>, external URL или raster data URI.
- После Sharp-рендера на ширине 375 px визуально просмотрены три финальные
PNG: <strong>375×719</strong>, <strong>375×641</strong> и
<strong>375×646</strong>. Clipping, наложения и горизонтальный overflow
внутри SVG не обнаружены.
### Фактически выполненные проверки
Команды запускались из <code>web/</code> после финальной редакторской правки:
<pre><code>node --check scripts/upgrade-2021-02.mjs
npm run audit:draft -- scripts/upgrade-2021-02.mjs
node scripts/upgrade-2021-02.mjs --verify-fixture
xmllint --noout \
public/assets/editorial/2021/cache-key-lifecycle-2021.svg \
public/assets/editorial/2021/cache-consistency-matrix-2021.svg \
public/assets/editorial/2021/cache-diagnosis-2021.svg</code></pre>
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | PASS, code 0 |
| Import-safe export и draft gate | PASS: **8 882 / 9 903 / 10 053** знака body; найдены три slug, tables, figures, code, routes, sources и visual assets |
| In-memory fixture | PASS: все восемь assertions равны <code>true</code>; stale read пересобрал v2, late event v2 сохранил current entry, private v3 не выдан публичному reader |
| <code>xmllint --noout</code> | PASS, все три SVG — корректный XML |
| Sharp mobile preflight | PASS: финальные PNG 375 px просмотрены; первая схема исправлена после первоначального статического рендера |
| Scope/self-review | PASS: revision не меняют <code>date</code>/<code>author</code>; registry, archive, README, queue, standard, package config, Git и чужие untracked files не редактировались |
Не запускались настоящий source storage, cache store, Redis, CDN, broker,
database, HTTP server, browser, CI, production build, deployment, external
test stand или screen reader. Static SVG preflight не заменяет browser review,
accessibility audit и integration test выбранной инфраструктуры.
## Итог
Статус: **тройное авторское ревью пройдено; П36 принята к отдельной
публикации**.
Созданы ровно пять файлов:
1. <code>web/scripts/upgrade-2021-02.mjs</code>;
2. <code>editorial/reviews/2021-02-draft.md</code>;
3. <code>web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg</code>;
4. <code>web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg</code>;
5. <code>web/public/assets/editorial/2021/cache-diagnosis-2021.svg</code>.
## Независимая интеграционная приёмка
Основной редактор 31 июля 2026 года подключил три revision к
<code>web/data/editorial-revisions.mjs</code>, не меняя базовый
<code>articles.json</code>, даты или автора архивных записей. В registry стало
103 revision. RFC 7234 и RFC 7232 сверены независимо: в феврале 2021 они
задавали HTTP cache semantics, а учебная <code>sourceVersion</code> остаётся
прикладной моделью, не ETag и не API cache store.
| Проверка после интеграции | Реальный результат |
| --- | --- |
| Строгий audit трёх slug | PASS: 8 882 / 9 903 / 10 053 знака; у каждой статьи есть figure, table и code examples |
| Production build | PASS: Next.js собрал 374 статические страницы |
| Независимый mobile visual review | PASS: основной редактор повторно просмотрел три SVG после Sharp-рендера в 375 px; clipping, overlap и overflow не обнаружены |
Ни этот отчёт, ни интеграция не утверждают запуск source storage, cache store,
Redis, CDN, broker, HTTP server, browser или assistive technology.
Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
публикационной операцией; Git остаётся источником её фактической записи.
+2
View File
@@ -32,6 +32,7 @@ import { revisions as october2020Revisions } from '../scripts/upgrade-2020-10.mj
import { revisions as november2020Revisions } from '../scripts/upgrade-2020-11.mjs'; import { revisions as november2020Revisions } from '../scripts/upgrade-2020-11.mjs';
import { revisions as december2020Revisions } from '../scripts/upgrade-2020-12.mjs'; import { revisions as december2020Revisions } from '../scripts/upgrade-2020-12.mjs';
import { revisions as january2021Revisions } from '../scripts/upgrade-2021-01.mjs'; import { revisions as january2021Revisions } from '../scripts/upgrade-2021-01.mjs';
import { revisions as february2021Revisions } from '../scripts/upgrade-2021-02.mjs';
// This layer replaces archived source entries without losing their stable slug and date. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -69,4 +70,5 @@ export const editorialRevisions = [
...november2020Revisions, ...november2020Revisions,
...december2020Revisions, ...december2020Revisions,
...january2021Revisions, ...january2021Revisions,
...february2021Revisions,
]; ];
@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1230" role="img" aria-labelledby="cache-consistency-matrix-title cache-consistency-matrix-desc">
<title id="cache-consistency-matrix-title">Матрица допустимых решений чтения при инвалидации кеша</title>
<desc id="cache-consistency-matrix-desc">Пять строк показывают отношения source версии, cache entry и события. Совпадающие версии дают hit, отставшая cache версия вызывает rebuild, а private source запрещает публичный ответ и очищает entry.</desc>
<defs>
<style>
.bg { fill: #101827; }
.title { fill: #f9fafb; font: 700 33px Arial, sans-serif; }
.subtitle { fill: #cbd5e1; font: 400 20px Arial, sans-serif; }
.header { fill: #1e3a5f; stroke: #60a5fa; stroke-width: 2; }
.row { fill: #172554; stroke: #334155; stroke-width: 2; }
.row-warn { fill: #3f1d2e; stroke: #fb7185; stroke-width: 2; }
.row-private { fill: #3b2f16; stroke: #fbbf24; stroke-width: 2; }
.head { fill: #e0f2fe; font: 700 21px Arial, sans-serif; }
.cell { fill: #e5e7eb; font: 400 21px Arial, sans-serif; }
.cell-ok { fill: #d1fae5; font: 700 21px Arial, sans-serif; }
.cell-warn { fill: #fecdd3; font: 700 21px Arial, sans-serif; }
.cell-private { fill: #fef3c7; font: 700 21px Arial, sans-serif; }
.line { stroke: #475569; stroke-width: 2; }
.foot { fill: #94a3b8; font: 400 19px Arial, sans-serif; }
</style>
</defs>
<rect class="bg" width="720" height="1230" rx="28"/>
<text class="title" x="42" y="64">Матрица: что вправе read</text>
<text class="subtitle" x="42" y="97">Один owner · одна public projection · учебная модель</text>
<rect class="header" x="34" y="132" width="652" height="76" rx="14"/>
<text class="head" x="54" y="163">SOURCE</text>
<text class="head" x="214" y="163">CACHE</text>
<text class="head" x="366" y="163">EVENT</text>
<text class="head" x="510" y="163">READ</text>
<text class="head" x="510" y="189">RESULT</text>
<path class="line" d="M190 132 L190 208 M344 132 L344 208 M488 132 L488 208"/>
<rect class="row" x="34" y="224" width="652" height="154" rx="14"/>
<text class="cell" x="54" y="260">public v1</text>
<text class="cell" x="214" y="260">public v1</text>
<text class="cell" x="366" y="260">нет</text>
<text class="cell-ok" x="510" y="260">hit-current</text>
<text class="cell" x="54" y="300">version равна</text>
<text class="cell" x="214" y="300">key совпал</text>
<text class="cell" x="366" y="300">—</text>
<text class="cell-ok" x="510" y="300">вернуть v1</text>
<path class="line" d="M190 224 L190 378 M344 224 L344 378 M488 224 L488 378"/>
<rect class="row-warn" x="34" y="394" width="652" height="154" rx="14"/>
<text class="cell" x="54" y="430">public v2</text>
<text class="cell" x="214" y="430">public v1</text>
<text class="cell" x="366" y="430">pending v2</text>
<text class="cell-warn" x="510" y="430">stale-rebuilt</text>
<text class="cell" x="54" y="470">source новее</text>
<text class="cell" x="214" y="470">entry старая</text>
<text class="cell" x="366" y="470">не доставлен</text>
<text class="cell-warn" x="510" y="470">вернуть v2</text>
<path class="line" d="M190 394 L190 548 M344 394 L344 548 M488 394 L488 548"/>
<rect class="row" x="34" y="564" width="652" height="154" rx="14"/>
<text class="cell" x="54" y="600">public v2</text>
<text class="cell" x="214" y="600">нет</text>
<text class="cell" x="366" y="600">applied v2</text>
<text class="cell-ok" x="510" y="600">miss-built</text>
<text class="cell" x="54" y="640">source current</text>
<text class="cell" x="214" y="640">evicted</text>
<text class="cell" x="366" y="640">v1 deleted</text>
<text class="cell-ok" x="510" y="640">собрать v2</text>
<path class="line" d="M190 564 L190 718 M344 564 L344 718 M488 564 L488 718"/>
<rect class="row" x="34" y="734" width="652" height="154" rx="14"/>
<text class="cell" x="54" y="770">public v2</text>
<text class="cell" x="214" y="770">public v2</text>
<text class="cell" x="366" y="770">late v2</text>
<text class="cell-ok" x="510" y="770">keep hit</text>
<text class="cell" x="54" y="810">version равна</text>
<text class="cell" x="214" y="810">уже rebuilt</text>
<text class="cell" x="366" y="810">v2 duplicate</text>
<text class="cell-ok" x="510" y="810">не delete</text>
<path class="line" d="M190 734 L190 888 M344 734 L344 888 M488 734 L488 888"/>
<rect class="row-private" x="34" y="904" width="652" height="166" rx="14"/>
<text class="cell" x="54" y="940">private v3</text>
<text class="cell" x="214" y="940">public v2</text>
<text class="cell" x="366" y="940">pending v3</text>
<text class="cell-private" x="510" y="940">not-visible</text>
<text class="cell" x="54" y="980">право изменилось</text>
<text class="cell" x="214" y="980">недопустима</text>
<text class="cell" x="366" y="980">не ждём</text>
<text class="cell-private" x="510" y="980">deny + evict</text>
<path class="line" d="M190 904 L190 1070 M344 904 L344 1070 M488 904 L488 1070"/>
<text class="foot" x="42" y="1132">TTL может ограничить жизнь entry, но не доказывает current version.</text>
<text class="foot" x="42" y="1164">Это не Redis, CDN, browser cache или измерение production traffic.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1240" role="img" aria-labelledby="cache-diagnosis-title cache-diagnosis-desc">
<title id="cache-diagnosis-title">Диагностика старой кешированной проекции</title>
<desc id="cache-diagnosis-desc">Вертикальная схема начинает со старой карточки у читателя, собирает source version, key, cache version, event и visibility. Затем она разделяет stale entry, неправильный ключ, private источник и проблему renderer вне кеша.</desc>
<defs>
<style>
.bg { fill: #111827; }
.title { fill: #f9fafb; font: 700 34px Arial, sans-serif; }
.subtitle { fill: #cbd5e1; font: 400 20px Arial, sans-serif; }
.symptom { fill: #4c1d2a; stroke: #fb7185; stroke-width: 2; }
.evidence { fill: #172554; stroke: #60a5fa; stroke-width: 2; }
.decision { fill: #312e81; stroke: #a5b4fc; stroke-width: 2; }
.action-ok { fill: #12332a; stroke: #34d399; stroke-width: 2; }
.action-warn { fill: #3b2f16; stroke: #fbbf24; stroke-width: 2; }
.head { fill: #ffffff; font: 700 26px Arial, sans-serif; }
.body { fill: #e5e7eb; font: 400 22px Arial, sans-serif; }
.body-blue { fill: #dbeafe; font: 400 22px Arial, sans-serif; }
.body-ok { fill: #d1fae5; font: 400 22px Arial, sans-serif; }
.body-warn { fill: #fef3c7; font: 400 22px Arial, sans-serif; }
.arrow { stroke: #94a3b8; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.note { fill: #94a3b8; font: 400 19px Arial, sans-serif; }
</style>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="#94a3b8"/>
</marker>
</defs>
<rect class="bg" width="720" height="1240" rx="28"/>
<text class="title" x="42" y="64">Диагноз stale-read</text>
<text class="subtitle" x="42" y="97">Сначала evidence, потом purge</text>
<rect class="symptom" x="48" y="140" width="624" height="118" rx="18"/>
<text class="head" x="72" y="182">Симптом</text>
<text class="body" x="72" y="216">source обновлён, reader видит old title</text>
<text class="body" x="72" y="246">Не удалять key до сохранения фактов.</text>
<path class="arrow" d="M360 258 L360 314"/>
<rect class="evidence" x="48" y="324" width="624" height="186" rx="18"/>
<text class="head" x="72" y="366">Evidence packet</text>
<text class="body-blue" x="72" y="402">1. source id и current version</text>
<text class="body-blue" x="72" y="432">2. read key и cached version</text>
<text class="body-blue" x="72" y="462">3. event state и reader visibility</text>
<text class="body-blue" x="72" y="494">Один title не доказывает причину.</text>
<path class="arrow" d="M360 510 L360 566"/>
<rect class="decision" x="48" y="576" width="624" height="112" rx="18"/>
<text class="head" x="72" y="618">Source version выше cached?</text>
<text class="body" x="72" y="652">Да → key тот же? event pending?</text>
<text class="body" x="72" y="680">Нет → искать другой read layer.</text>
<path class="arrow" d="M190 688 L190 744"/>
<path class="arrow" d="M530 688 L530 744"/>
<rect class="action-ok" x="48" y="754" width="292" height="186" rx="18"/>
<text class="head" x="72" y="796">Stale entry</text>
<text class="body-ok" x="72" y="832">source v2, cache v1</text>
<text class="body-ok" x="72" y="862">read: rebuild v2</text>
<text class="body-ok" x="72" y="892">late v2: keep current</text>
<text class="body-ok" x="72" y="922">Не выдавать v1 как hit.</text>
<rect class="action-warn" x="380" y="754" width="292" height="186" rx="18"/>
<text class="head" x="404" y="796">Не тот key</text>
<text class="body-warn" x="404" y="832">scope или locale lost</text>
<text class="body-warn" x="404" y="862">исправить key contract</text>
<text class="body-warn" x="404" y="892">не purge correct entry</text>
<text class="body-warn" x="404" y="922">до проверки collision.</text>
<path class="arrow" d="M190 940 L190 994"/>
<path class="arrow" d="M530 940 L530 994"/>
<rect class="action-warn" x="48" y="1004" width="292" height="142" rx="18"/>
<text class="head" x="72" y="1046">Source private</text>
<text class="body-warn" x="72" y="1080">deny + evict before event</text>
<text class="body-warn" x="72" y="1110">Право выше TTL.</text>
<rect class="action-ok" x="380" y="1004" width="292" height="142" rx="18"/>
<text class="head" x="404" y="1046">Versions equal</text>
<text class="body-ok" x="404" y="1080">проверить renderer, client</text>
<text class="body-ok" x="404" y="1110">или другой cache layer.</text>
<text class="note" x="48" y="1200">Учебная Map-модель · не browser · не cache store · не broker</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1380" role="img" aria-labelledby="cache-key-lifecycle-title cache-key-lifecycle-desc">
<title id="cache-key-lifecycle-title">Учебный жизненный цикл версии кешированной публичной проекции</title>
<desc id="cache-key-lifecycle-desc">Вертикальная схема показывает запись source версии два, публичный ключ, запоздавшее событие ArticleChanged версии два, сравнение cache версии один с source версией два и пересборку публичной проекции до ответа читателю.</desc>
<defs>
<style>
.bg { fill: #0f172a; }
.title { fill: #f8fafc; font: 700 34px Arial, sans-serif; }
.subtitle { fill: #cbd5e1; font: 400 21px Arial, sans-serif; }
.card { fill: #172554; stroke: #60a5fa; stroke-width: 2; }
.card-warn { fill: #3f1d2e; stroke: #fb7185; stroke-width: 2; }
.card-ok { fill: #12332a; stroke: #34d399; stroke-width: 2; }
.badge { fill: #1d4ed8; }
.badge-warn { fill: #be123c; }
.badge-ok { fill: #047857; }
.badge-text { fill: #ffffff; font: 700 20px Arial, sans-serif; }
.head { fill: #f8fafc; font: 700 26px Arial, sans-serif; }
.body { fill: #dbeafe; font: 400 22px Arial, sans-serif; }
.body-warn { fill: #fecdd3; font: 400 22px Arial, sans-serif; }
.body-ok { fill: #d1fae5; font: 400 22px Arial, sans-serif; }
.mono { fill: #e0f2fe; font: 700 21px "Courier New", monospace; }
.arrow { stroke: #94a3b8; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.note { fill: #94a3b8; font: 400 19px Arial, sans-serif; }
</style>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="#94a3b8"/>
</marker>
</defs>
<rect class="bg" width="720" height="1380" rx="28"/>
<text class="title" x="48" y="66">Key → version → read</text>
<text class="subtitle" x="48" y="101">Учебный contract одной public проекции</text>
<rect class="card" x="48" y="142" width="624" height="170" rx="18"/>
<rect class="badge" x="72" y="166" width="102" height="38" rx="19"/>
<text class="badge-text" x="91" y="192">WRITE</text>
<text class="head" x="72" y="234">Source owner записал v2</text>
<text class="body" x="72" y="266">article guide-42 · visibility public</text>
<text class="body" x="72" y="296">Только source owner создаёт version.</text>
<path class="arrow" d="M360 312 L360 372"/>
<rect class="card" x="48" y="382" width="624" height="170" rx="18"/>
<rect class="badge" x="72" y="406" width="82" height="38" rx="19"/>
<text class="badge-text" x="92" y="432">KEY</text>
<text class="head" x="72" y="474">Reader scope входит в key</text>
<text class="mono" x="72" y="506">article:public:guide-42</text>
<text class="body" x="72" y="536">editorNote не входит в public value.</text>
<path class="arrow" d="M360 552 L360 612"/>
<rect class="card-warn" x="48" y="622" width="624" height="176" rx="18"/>
<rect class="badge-warn" x="72" y="646" width="98" height="38" rx="19"/>
<text class="badge-text" x="91" y="672">EVENT</text>
<text class="head" x="72" y="714">ArticleChanged v2 запоздало</text>
<text class="body-warn" x="72" y="746">Cache ещё хранит entry sourceVersion v1.</text>
<text class="body-warn" x="72" y="776">Delivery не равно праву вернуть v1.</text>
<path class="arrow" d="M360 798 L360 858"/>
<rect class="card-warn" x="48" y="868" width="624" height="176" rx="18"/>
<rect class="badge-warn" x="72" y="892" width="94" height="38" rx="19"/>
<text class="badge-text" x="90" y="918">READ</text>
<text class="head" x="72" y="960">Сравнение до cache hit</text>
<text class="body-warn" x="72" y="992">source v2 ≠ cached v1 → stale, не hit</text>
<text class="body-warn" x="72" y="1022">Собираем projection только из public fields.</text>
<path class="arrow" d="M360 1044 L360 1104"/>
<rect class="card-ok" x="48" y="1114" width="624" height="184" rx="18"/>
<rect class="badge-ok" x="72" y="1138" width="98" height="38" rx="19"/>
<text class="badge-text" x="90" y="1164">SAFE</text>
<text class="head" x="72" y="1206">stale-rebuilt → public v2</text>
<text class="body-ok" x="72" y="1238">Поздний event v2 сохраняет current entry v2.</text>
<text class="body-ok" x="72" y="1268">Если source стал private: deny и evict.</text>
<text class="note" x="48" y="1342">Map in memory · не Redis · не broker · не CDN</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+631
View File
@@ -0,0 +1,631 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const length = bodyText(contentHtml).length;
if (length < 5000 || length > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length);
}
return {
...meta,
contentHtml,
};
}
const rfc7234 = {
title: 'RFC 7234 — HTTP/1.1 Caching, июнь 2014',
url: 'https://www.rfc-editor.org/rfc/rfc7234',
note: 'исторический стандарт, действовавший в феврале 2021 года: задаёт ключи, reuse, validation и invalidation HTTP-ответов; не является API прикладного cache store',
};
const rfc7232 = {
title: 'RFC 7232 — HTTP/1.1 Conditional Requests, июнь 2014',
url: 'https://www.rfc-editor.org/rfc/rfc7232',
note: 'описывает entity-tag, If-None-Match и 304 как HTTP-механизм проверки представления; эти поля не заменяют версию прикладной записи',
};
const trainingNotice = 'Все идентификаторы, заголовки, версии, события и результаты ниже учебные. Модель работает только с Map в памяти: она не подключает Redis, CDN, broker, framework, HTTP-клиент или production traffic.';
function assertTrainingId(id) {
if (!/^[a-z0-9-]+$/.test(String(id))) {
throw new Error('Training id must use lowercase letters, digits and hyphen');
}
}
function assertVisibility(visibility) {
if (visibility !== 'public' && visibility !== 'private') {
throw new Error('Training visibility must be public or private');
}
}
/**
* Ключ является частью read contract. В упражнении readerScope не угадывается:
* public projection получает отдельный key и не содержит editorNote.
*/
export function publicProjectionKey(id) {
assertTrainingId(id);
return 'article:public:' + id;
}
function createPublicProjection(record) {
return Object.freeze({
id: record.id,
title: record.title,
sourceVersion: record.version,
readerScope: 'public',
});
}
/**
* Учебная модель одного source owner, одного public projection и одного
* versioned invalidation event. Это не cache adapter и не формат сообщения
* для настоящего broker-а.
*/
export function createTrainingCacheModel() {
const source = new Map();
const cache = new Map();
const events = [];
function writeSource({ id, title, visibility = 'public', editorNote = 'training only' }) {
assertTrainingId(id);
assertVisibility(visibility);
const previous = source.get(id);
const record = Object.freeze({
id,
title: String(title),
visibility,
editorNote: String(editorNote),
version: previous ? previous.version + 1 : 1,
});
const event = Object.freeze({
eventId: 'article-changed-' + id + '-v' + record.version,
type: 'ArticleChanged',
id,
sourceVersion: record.version,
visibility: record.visibility,
cacheKey: publicProjectionKey(id),
});
source.set(id, record);
events.push(event);
return { record, event };
}
function applyInvalidation(event) {
if (!event || event.type !== 'ArticleChanged') {
throw new Error('Expected ArticleChanged training event');
}
const entry = cache.get(event.cacheKey);
if (!entry) {
return { eventId: event.eventId, action: 'no-entry', cacheKey: event.cacheKey };
}
if (entry.sourceVersion < event.sourceVersion) {
cache.delete(event.cacheKey);
return {
eventId: event.eventId,
action: 'evicted-older-entry',
cacheKey: event.cacheKey,
entryVersion: entry.sourceVersion,
};
}
return {
eventId: event.eventId,
action: 'kept-current-entry',
cacheKey: event.cacheKey,
entryVersion: entry.sourceVersion,
};
}
function readPublicProjection(id) {
assertTrainingId(id);
const cacheKey = publicProjectionKey(id);
const record = source.get(id);
const entry = cache.get(cacheKey);
if (!record || record.visibility !== 'public') {
if (entry) cache.delete(cacheKey);
return {
status: 'not-visible',
cacheKey,
sourceVersion: record ? record.version : null,
evictedCachedProjection: Boolean(entry),
};
}
if (entry && entry.sourceVersion === record.version) {
return {
status: 'hit-current',
cacheKey,
sourceVersion: record.version,
projection: entry.projection,
};
}
if (entry && entry.sourceVersion > record.version) {
throw new Error('Cache entry cannot be newer than the training source');
}
const projection = createPublicProjection(record);
cache.set(cacheKey, { sourceVersion: record.version, projection });
return {
status: entry ? 'stale-rebuilt' : 'miss-built',
cacheKey,
sourceVersion: record.version,
priorCacheVersion: entry ? entry.sourceVersion : null,
projection,
};
}
function inspect() {
return {
source: [...source.values()].map(({ editorNote, ...record }) => record),
cache: [...cache.entries()].map(([cacheKey, entry]) => ({
cacheKey,
sourceVersion: entry.sourceVersion,
projection: entry.projection,
})),
events: [...events],
};
}
return {
writeSource,
applyInvalidation,
readPublicProjection,
inspect,
};
}
/**
* Детерминированный stale/read сценарий:
* запись v2 уже попала в source, а event v2 ещё не доставлен. Read сравнивает
* sourceVersion и rebuild-ит projection до возвращения читателю.
*/
export function runCacheInvalidationFixture() {
const model = createTrainingCacheModel();
const firstWrite = model.writeSource({
id: 'guide-42',
title: 'Кеш: версия один',
visibility: 'public',
editorNote: 'не входит в public projection',
});
const firstEvent = model.applyInvalidation(firstWrite.event);
const firstRead = model.readPublicProjection('guide-42');
const secondWrite = model.writeSource({
id: 'guide-42',
title: 'Кеш: версия два',
visibility: 'public',
editorNote: 'всё ещё не входит в public projection',
});
const staleReadBeforeEvent = model.readPublicProjection('guide-42');
const delayedSecondEvent = model.applyInvalidation(secondWrite.event);
const currentHit = model.readPublicProjection('guide-42');
const privateWrite = model.writeSource({
id: 'guide-42',
title: 'Скрытая версия три',
visibility: 'private',
editorNote: 'только учебная заметка редактора',
});
const hiddenReadBeforeEvent = model.readPublicProjection('guide-42');
const privateEvent = model.applyInvalidation(privateWrite.event);
return {
steps: {
firstEvent,
firstRead,
staleReadBeforeEvent,
delayedSecondEvent,
currentHit,
hiddenReadBeforeEvent,
privateEvent,
},
snapshot: model.inspect(),
assertions: {
firstReadBuiltVersionOne: firstRead.status === 'miss-built'
&& firstRead.sourceVersion === 1
&& firstRead.projection.title === 'Кеш: версия один',
staleReadRebuiltVersionTwo: staleReadBeforeEvent.status === 'stale-rebuilt'
&& staleReadBeforeEvent.priorCacheVersion === 1
&& staleReadBeforeEvent.sourceVersion === 2
&& staleReadBeforeEvent.projection.title === 'Кеш: версия два',
delayedEventDidNotEvictCurrentVersion: delayedSecondEvent.action === 'kept-current-entry'
&& delayedSecondEvent.entryVersion === 2,
currentHitReadsVersionTwo: currentHit.status === 'hit-current'
&& currentHit.sourceVersion === 2
&& currentHit.projection.title === 'Кеш: версия два',
projectionDoesNotExposeEditorNote: !Object.hasOwn(currentHit.projection, 'editorNote'),
privateSourceIsNotVisibleBeforeEvent: hiddenReadBeforeEvent.status === 'not-visible'
&& hiddenReadBeforeEvent.sourceVersion === 3
&& hiddenReadBeforeEvent.evictedCachedProjection === true,
privateEventFindsNoPublicEntry: privateEvent.action === 'no-entry',
onlyTrainingDataWasUsed: model.inspect().source.length === 1
&& model.inspect().events.length === 3,
},
};
}
const keyContractCode = [
"function publicProjectionKey(id) {",
" return 'article:public:' + id;",
"}",
"",
"// readerScope встроен в key, а editorNote не входит в projection.",
"const key = publicProjectionKey('guide-42');",
"// article:public:guide-42",
];
const writeAndEventCode = [
"const record = {",
" id: 'guide-42',",
" title: 'Кеш: версия два',",
" visibility: 'public',",
" version: previous.version + 1,",
"};",
"",
"const event = {",
" type: 'ArticleChanged',",
" id: record.id,",
" sourceVersion: record.version,",
" cacheKey: publicProjectionKey(record.id),",
"};",
];
const invalidationCode = [
"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' };",
"}",
];
const guardedReadCode = [
"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 };",
"}",
];
const fixtureCommandCode = [
"# Запускается только модель Map из revision-модуля.",
"node scripts/upgrade-2021-02.mjs --verify-fixture",
"",
"# Ожидаемые истинные assertions:",
"staleReadRebuiltVersionTwo: true",
"delayedEventDidNotEvictCurrentVersion: true",
"privateSourceIsNotVisibleBeforeEvent: true",
];
const fixtureResultCode = [
"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",
];
const practiceArticle = createRevision(
{
slug: 'editorial-2021-02-practice-cache-invalidation',
title: 'Инвалидация кеша: ключ, событие и проверка чтения',
categories: ['Кеширование', 'Backend', 'Архитектура'],
cover: '/assets/editorial/2021/cache-key-lifecycle-2021.svg',
excerpt: 'Учебный контракт для одного публичного представления: владелец записи, key с областью читателя, версия, событие изменения и read с защитой от stale entry.',
readingMinutes: 15,
},
[
paragraph('Симптом обычно виден не в кеше, а на странице: источник уже хранит новый заголовок, а читатель получает старую карточку. Цена ошибки зависит от проекции. Можно показать устаревший статус публикации, оставить доступным снятый материал или скрыть уже разрешённое изменение. Увеличить TTL либо удалить случайный key после жалобы — это не исправление: при следующей записи тот же читатель снова увидит не ту версию.'),
paragraph('Для февраля 2021 я бы начал не с выбора Redis или CDN, а с короткого контракта. Нужно назвать владельца исходной записи, ключ именно читаемой проекции, событие изменения и факт, по которому read вправе вернуть значение. Главный инвариант здесь не «кеш быстрый», а «читатель получает только данные, которые вправе видеть в текущем состоянии источника». Ниже все значения синтетические и живут в <code>Map</code>; они объясняют порядок, но не изображают работающую инфраструктуру.'),
heading('Сначала определяем границу чтения'),
paragraph('Кеширует не таблица и не объект целиком, а конкретный ответ на конкретный вопрос. В упражнении таким вопросом будет «что может увидеть публичный читатель у article <code>guide-42</code>?». Исходная запись принадлежит одному source owner. У неё есть <code>visibility</code>, <code>title</code>, внутренняя заметка редактора и монотонная <code>version</code>. Публичная проекция содержит только id, title, version и область читателя. Внутренняя заметка не должна попасть в неё даже при cache hit.'),
dataTable(
'Контракт одной публичной карточки',
['Часть', 'Владелец или значение', 'Почему нужна', 'Проверка'],
[
['Источник', '<code>article:guide-42</code> у учебного source owner', 'только он создаёт следующую версию', 'после write version растёт с 1 до 2'],
['Read key', '<code>article:public:guide-42</code>', 'key отделяет public projection от другой области чтения', 'reader scope явно виден в key'],
['Проекция', '<code>id</code>, <code>title</code>, <code>sourceVersion</code>', 'read не переносит редакторское поле по привычке', 'в object нет <code>editorNote</code>'],
['Событие', '<code>ArticleChanged</code> с id и <code>sourceVersion</code>', 'сообщает, для какой версии прежняя entry стала подозрительной', 'event v2 сравнивается с cached v1'],
['Критерий hit', 'cached version равна source version', 'TTL не маскирует уже известную новую запись', 'иначе read rebuild-ит projection'],
],
),
paragraph('У HTTP есть похожая, но не идентичная граница. RFC 7234 описывает cache entry через key и reuse response для эквивалентного request; primary key там связан с методом и target URI, а при content negotiation появляются дополнительные selecting headers. Это полезная дисциплина: один URL без языка, прав или представления часто недостаточен. Но RFC не даёт generic function для прикладной памяти. Поэтому ниже key — часть учебного read contract, а не притворный универсальный API для любого cache store.'),
heading('Key обязан включать право увидеть проекцию'),
paragraph('Плохой key выглядит удобно: <code>article:guide-42</code>. Он быстро строится, но ничего не говорит, какое представление там лежит. Если одна ветка кода записала публичную карточку, а другая позднее ожидает редакторскую, collision уже создан. Нельзя исправить это договорённостью «у нас такой key только для public»: она не проверяется на чтении. Лучше назвать область прямо и строить projection whitelist отдельно от source record.'),
codeBlock(keyContractCode),
paragraph('Такой фрагмент не решает authorization. Он только делает её границу видимой там, где рождается cache entry. Реальный проект может иметь tenant, язык, role, feature state или digest query. Их нельзя бездумно дописать в строку и считать задачу закрытой: каждое поле должно влиять на то, что читатель вправе увидеть. Если поле не влияет на проекцию, оно дробит cache и скрывает диагностику. Если влияет, но отсутствует, разные читатели получают одну запись по ошибке.'),
figure(
'/assets/editorial/2021/cache-key-lifecycle-2021.svg',
'Вертикальная схема учебного жизненного цикла: source owner записывает article guide-42 версии 2, public key article:public:guide-42 хранит прежнюю entry версии 1, событие ArticleChanged v2 запаздывает, а read сравнивает версии и пересобирает только публичную проекцию',
'Версия на read-path нужна не для украшения event. Она не даёт вернуть v1 в момент, когда source уже находится на v2, но delivery события ещё не дошла до модели.',
),
heading('Запись создаёт версию и повод для invalidation'),
paragraph('После изменения source owner должен оставить два связанных факта. Первый — сама запись с новой version. Второй — событие, которое указывает на изменившийся объект и ту же version. Нельзя выпускать event «очистить всё» без владельца и версии: оно не объясняет, какой cache key должен исчезнуть и как поздний потребитель отличит старое сообщение от нового. В нашем контракте write создаёт <code>ArticleChanged</code>, но не делает вид, что это уже доставка через broker.'),
codeBlock(writeAndEventCode),
paragraph('Факт успешной записи важнее намерения. RFC 7234 для HTTP-кеша связывает invalidation с неошибочным ответом на unsafe request и отдельно предупреждает, что это не гарантирует очистку всех подходящих ответов в других кешах. Этот предел полезно перенести в разговор о приложении: событие может существовать, а конкретная entry ещё оставаться в другом слое. Поэтому «мы отправили event» не равно «читатель уже не увидит старое». Нужны ключ, версия и наблюдаемая проверка на read-path.'),
heading('Событие ускоряет очистку, но read всё равно проверяет версию'),
paragraph('Счастливый путь короткий: cache entry v1 существует; source записывает v2; invalidation v2 находит entry v1 и удаляет её; следующий read строит v2. Но на практике опаснее промежуток между двумя шагами. Source уже v2, а event пока не применён. Если read доверяет только наличию key или TTL, он вернёт v1. В учебной модели read сравнивает <code>entry.sourceVersion</code> с <code>record.version</code>. Несовпадение не считается hit: entry пересобирается до ответа.'),
paragraph('Это не обещание строгой согласованности любой распределённой системы. Модель смотрит на source synchronously, поэтому может сравнить две версии в одной памяти. Реальный cache store, broker и source storage могут иметь другие границы, задержки и подтверждения. Но контракт полезен уже сейчас: он явно говорит, что cache hit разрешён только при совпадении известной версии и области читателя. Где нельзя получить version source на read, нужно честно выбрать другой механизм и отдельно описать его окно stale.'),
heading('Проверяем модель до интеграции'),
paragraph(trainingNotice),
codeBlock(fixtureCommandCode),
paragraph('Fixture проходит один устойчивый stale/read сценарий. Сначала source v1 строит cache entry v1. Затем source меняется на v2, но event v2 намеренно ещё не применяется. Read видит entry v1 и source v2, возвращает <code>stale-rebuilt</code> с публичной проекцией v2. Когда запоздавшее event приходит позже, оно не удаляет уже current entry v2. В конце source делает запись private v3: public read обязан отказаться от выдачи и удалить предыдущую public entry ещё до delivery события.'),
heading('Нумерованный маршрут для одного контракта'),
orderedList([
'Назвать один читательский вопрос и цену stale ответа. Не начинать с общего «почистим кеш».',
'Назначить source owner: именно он создаёт следующую version и определяет visibility записи.',
'Собрать key из объекта и тех условий, которые меняют право увидеть проекцию. Для public карточки сохранить область <code>public</code> в key.',
'Сделать projection whitelist. Проверить, что внутренние поля не попадают в value даже на cache hit.',
'После успешного write сформировать event с id, version и key или детерминированным способом его построить. Не выдавать локальный object за broker delivery.',
'На read сравнить cached version с текущей source version. При несовпадении rebuild-ить либо выбрать документированный другой путь, а не вернуть stale как hit.',
'Добавить два отрицательных случая: задержанное event и изменение visibility. Оба должны дать безопасный результат для читателя.',
]),
heading('Граница этого практического рецепта'),
paragraph('В этой статье нет настоящего cache hit-rate, CDN, Redis, очереди, HTTP response или данных пользователя. RFC 7234 и RFC 7232 объясняют HTTP semantics, но не говорят, как конкретный framework хранит объект в памяти. TTL тоже не запрещён: он ограничивает жизнь entry и полезен как дополнительный предел. Он не заменяет contract изменения, если source уже знает новую version. Реальную policy надо связывать с выбранным storage, нагрузкой, правами и допустимым окном stale, а не переносить этот учебный код в production без проверки.'),
paragraph('Ожидаемый результат после такого разбора скромный и проверяемый: у одной проекции есть владелец, key, version, event и условие current hit. Если любой из пяти пунктов нельзя назвать, инвалидация пока является надеждой на срок жизни записи. Начните с одного пути чтения, запустите fixture и только затем добавляйте интеграционный test на разрешённом cache store или HTTP-маршруте.'),
],
[rfc7234, rfc7232],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-02-mechanism-cache-invalidation',
title: 'Инвалидация кеша: почему TTL не заменяет version',
categories: ['Кеширование', 'HTTP', 'Архитектура'],
cover: '/assets/editorial/2021/cache-consistency-matrix-2021.svg',
excerpt: 'Разбираем механизм versioned invalidation: source owner создаёт факт изменения, event чистит только более старую entry, а read отличает current hit от stale value.',
readingMinutes: 16,
},
[
paragraph('Симптом механической ошибки звучит так: source уже записал v2, но cache entry v1 ещё выглядит валидной, потому что её TTL не истёк. Цена — выдать читателю старую проекцию именно после известного изменения. Такая ошибка коварна: cache работает быстро, лог event может быть зелёным, а спорный ответ появляется только в узком порядке write, delayed invalidation и read.'),
paragraph('Причина обычно не в самом слове invalidation. У команды нет единого ответа на четыре вопроса: кто владеет source, что образует key, какая версия входит в событие и какое условие разрешает hit. В учебном контракте один source owner обновляет запись, одно событие сообщает её version, а read сравнивает cached version с source version. Это не замена Redis, CDN или HTTP caching: это минимальная модель, в которой можно разобрать порядок без реального трафика.'),
heading('Историческая граница HTTP-кеширования'),
paragraph('В феврале 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 может пройти через часть кешей, а подходящие ответы могут остаться в других.'),
paragraph('Эта норма не превращает прикладной event в HTTP request и не даёт нам право назвать любую <code>Map</code> HTTP cache. Она даёт полезный язык: reuse разрешён не просто потому, что есть значение, а при соблюдении ключа, свежести или validation. RFC 7232 дополняет его validators: <code>If-None-Match</code> позволяет проверять представление через entity-tag и получать <code>304 Not Modified</code>. В нашем коде <code>sourceVersion</code> — не ETag и не header. Это отдельная версия учебного source record, выбранная для детерминированной модели.'),
heading('Version связывает три разных состояния'),
paragraph('Одна цифра нужна в трёх местах. В source она говорит, какую запись создал владелец. В event она говорит, на какую запись ссылается invalidation. В cache entry она говорит, из какой source version собрана проекция. Если хотя бы одно место живёт отдельной нумерацией, уже нельзя объяснить, действительно ли entry v1 устарела для event v2. Timestamp не всегда помогает: у него может быть разная точность и источник, а версия выражает порядок именно одного owner.'),
dataTable(
'Матрица состояний в учебной модели',
['Source', 'Cache entry', 'Event v2', 'Что вправе вернуть read'],
[
['v1 public', 'v1 public', 'нет', '<code>hit-current</code>: версия и visibility совпадают'],
['v2 public', 'v1 public', 'ещё не применён', '<code>stale-rebuilt</code>: вернуть rebuilt v2, не v1'],
['v2 public', 'нет', 'применён', '<code>miss-built</code>: собрать public projection v2'],
['v2 public', 'v2 public', 'пришёл поздно', '<code>hit-current</code>: event v2 не удаляет entry v2'],
['v3 private', 'v2 public', 'ещё не применён', '<code>not-visible</code>: evict v2 и ничего не показать'],
],
),
paragraph('Последняя строка не является факультативной. Если visibility меняется, прежняя публичная entry становится не просто старой, а недопустимой для читателя. Read сначала смотрит на source record и только затем считает entry hit. Так он не ждёт таймер и не надеется на delivery сообщения, когда право показа уже исчезло. В системе с отдельной auth boundary реализация будет другой, но порядок вопроса остаётся: проверка права должна предшествовать возврату cache value.'),
figure(
'/assets/editorial/2021/cache-consistency-matrix-2021.svg',
'Вертикальная матрица пяти учебных состояний: совпадающие source и cache версии дают current hit, source v2 против cache v1 даёт stale rebuild даже до event, а private source v3 заставляет удалить public entry и отказать в чтении',
'Матрица показывает не latency и не реальный hit-rate, а допустимое решение read для пяти фиксированных комбинаций source, cache и события.',
),
heading('Invalidation удаляет только действительно старую entry'),
paragraph('Наивный consumer удаляет key для каждого пришедшего event. Это создаёт другой дефект: event v2 задержалось, read уже успел построить v2, а позднее сообщение стирает current value. Следующий read будет лишним miss. Хуже, если у consumer есть несколько delivery попыток и нет наблюдаемого правила. В учебном обработчике event удаляет entry только при <code>entry.sourceVersion &lt; event.sourceVersion</code>. Равная version означает, что cache уже current относительно этого события.'),
codeBlock(invalidationCode),
paragraph('Это правило не делает event order полностью безопасным для всех систем. Например, event может нести не тот key, source owner может выдавать версии неатомарно, а два разных projection key могут зависеть от одной записи. Для такой схемы нужен расширенный dependency contract, а не более смелый знак сравнения. Но для одного owner и одного key правило полезно: оно различает «очистить старое» и «снести уже построенное текущее».'),
heading('Read-path закрывает окно до delivery'),
paragraph('Теперь важный контрпример. Source записал v2. Event существует в памяти, но applyInvalidation ещё не вызван. Cache по key всё ещё содержит v1. Если read делает только <code>cache.get(key)</code>, он выдаёт stale. Если read сравнивает versions, он видит <code>1 !== 2</code>, строит новую public projection и заменяет entry. Именно это fixture называет <code>stale-rebuilt</code>. Результат не равен HTTP validation и не доказывает, что реальный source storage доступен так же быстро; он показывает явный выбор нашего контракта.'),
codeBlock(guardedReadCode),
paragraph('Равенство version ещё не достаточно без visibility. Если source v3 стал private, entry v2 может совпадать с последней известной cache version, но уже нарушает правило выдачи. Поэтому пример проверяет <code>record.visibility</code> до cache hit, удаляет public key и возвращает <code>not-visible</code>. Полезная мелочь: public projection строится функцией whitelist, а не copy всего record. Тогда вы не надеетесь, что новый внутренний field случайно не попадёт в сериализацию следующего месяца.'),
heading('TTL — дополнительный срок, а не доказательство current'),
paragraph('TTL полезен, когда нужно ограничить рост памяти или допустимое время без обращения к source. Он делает entry временной, но не сообщает, что произошло после её записи. Если запись source v2 уже успешна, пяти минут freshness для v1 недостаточно, чтобы назвать ответ правильным. В HTTP cache freshness и validation регулируются RFC; в приложении можно выбрать TTL, version check, explicit invalidation либо иной protocol. Нельзя взять имя одной директивы и объявить, что она решит все слои одинаково.'),
paragraph('При этом version check тоже имеет цену. В нашей <code>Map</code> read видит source напрямую; реальное чтение source может быть дорого, иметь replica lag или быть запрещено на public path. Тогда нельзя молча сохранить этот алгоритм. Нужно записать где хранится version, какую гарантию получает read, как распространяется invalidation и какой stale window бизнес допускает. Автор М4 в этом месте уже видит границу между data owner и projection, но не выдумывает согласованность там, где её не проверял.'),
heading('Fixture фиксирует порядок без настоящего broker'),
paragraph(trainingNotice),
codeBlock(fixtureCommandCode),
paragraph('Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2; projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object. Он не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.'),
heading('Маршрут проектирования механизма'),
orderedList([
'Для одного 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.',
]),
heading('Ограничения и следующий проверяемый шаг'),
paragraph('Учебный model не делает distributed transaction между source и event. Он не утверждает, что запись в базу и отправка в broker происходят атомарно, не измеряет задержку и не заменяет outbox, retry или reconciliation. Он также не моделирует multi-region, несколько reader role, pagination или key invalidation по тегам. Эти темы требуют отдельной статьи с конкретным storage и его документацией.'),
paragraph('Зато у механизма есть проверяемый результат: любой cache hit можно объяснить парой <code>key + sourceVersion</code> и текущим правом читателя. Если в логе или fixture нельзя показать эту пару, не надо спорить о TTL. Сначала зафиксируйте контракт одного ключа, добавьте stale-before-event сценарий и проверьте, что late event не разрушает уже current projection.'),
],
[rfc7234, rfc7232],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-02-field-cache-invalidation',
title: 'Разбор stale-read: как найти старую проекцию без догадок',
categories: ['Кеширование', 'Отладка', 'Backend'],
cover: '/assets/editorial/2021/cache-diagnosis-2021.svg',
excerpt: 'Полевой учебный разбор: собираем source version, key, cache entry, event и reader scope, чтобы отличить stale cache от неправильной проекции или отсутствующего права.',
readingMinutes: 16,
},
[
paragraph('Симптом полевого разбора: редактор видит новую запись в source, а публичный читатель получает предыдущий title. Цена не только в одной жалобе. Если сразу очистить весь кеш, мы временно скроем след и не узнаем, какой key дал старую проекцию, была ли event задержана и имел ли этот читатель право видеть новую запись. Следующая такая ошибка появится под другим URL и снова будет выглядеть случайной.'),
paragraph('Ниже нет настоящего инцидента, user data, cache log или HTTP-запроса. Это controlled fixture с одним synthetic article <code>guide-42</code>. В нём source v1 строит public entry v1, source меняется на v2, event v2 задерживается, а read обязан rebuild-ить v2 до выдачи. Затем source становится private v3, и public read обязан отказаться от ответа ещё до delivery v3. Такая последовательность полезна именно тем, что каждый переход задан и не смешан с инфраструктурным шумом.'),
heading('Собираем пять фактов до очистки key'),
paragraph('При stale-read нельзя начинать с причины «кеш не очистился». Это только гипотеза. Сначала нужен evidence packet из пяти значений: идентификатор source object, его current version, вычисленный read key, version cache entry и факт event. Шестое значение — reader scope или visibility — определяет, вправе ли читатель вообще получить проекцию. Если взять только title из source и title из ответа, вы увидите расхождение, но не сможете отличить старую entry от ключа другой области чтения.'),
dataTable(
'Минимальный evidence packet для одного stale-read',
['Факт', 'Учебное значение', 'Что отделяет', 'Нельзя заключить'],
[
['Source id', '<code>guide-42</code>', 'какой объект изменял владелец', 'что все зависимые keys уже найдены'],
['Current version', '<code>2</code>', 'запись source v2 от cache v1', 'что v2 уже доставлена через broker'],
['Read key', '<code>article:public:guide-42</code>', 'публичную проекцию от другого context', 'что key покрывает tenant, язык или role конкретного проекта'],
['Cache version', '<code>1</code>', 'наблюдаемую stale entry', 'что TTL настроен неверно'],
['Event', '<code>ArticleChanged v2</code>, pending', 'окно write → delivery → read', 'что реальный consumer уже получил сообщение'],
['Visibility', '<code>public</code>, затем <code>private</code>', 'разницу между stale и недопустимым ответом', 'что authorization всего приложения проверена'],
],
),
paragraph('В реальной системе эти факты могут жить в разных местах. Source version приходит из базы или service API, key вычисляет application, entry видна в cache store, event виден в broker или outbox. Здесь они специально находятся в одном object, потому что мы проверяем логику, а не доступ к стенду. Не надо подменять отсутствие доступа вымышленными ID и timestamps. Если факта нет, честная запись диагностики звучит так: «пока не знаем, какое состояние read сравнил с source».'),
figure(
'/assets/editorial/2021/cache-diagnosis-2021.svg',
'Вертикальная схема диагностики stale-read: от симптома старой карточки собираются source version, public key, cache version, event state и visibility; затем ветки ведут к rebuild stale entry, исправлению key, отказу private reader или проверке renderer вне кеша',
'Схема не назначает виновника по одному старому title. Она сначала отделяет four conditions, которые требуют разных действий.',
),
heading('Воспроизводим задержанное событие'),
paragraph('Fixture намеренно не применяет event v2 сразу. После write source v2 cache всё ещё содержит v1. Следующий read сравнивает две versions, пересобирает public projection и возвращает <code>stale-rebuilt</code>. Потом delivery v2 видит entry v2 и отвечает <code>kept-current-entry</code>. Это важная проверка против «очищать по каждому event»: позднее сообщение не должно создавать лишний miss и прятать уже правильную entry.'),
codeBlock(fixtureResultCode),
paragraph('Такая строка результата не является таймлайном production. В ней нет миллисекунд, host, account, URL, ответа HTTP или queue offset. Здесь важен порядок: v2 записана до read, event доставлена после read. Если проверяемая среда не может гарантировать, что read увидит source v2, этот конкретный verdict нельзя переносить туда. Тогда задача меняется: описать реплику, stale window и механизм validation выбранного стека, а не выкручивать условие в примере.'),
codeBlock(fixtureCommandCode),
heading('Отделяем stale entry от другого дефекта'),
paragraph('Первый вариант: 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, который различает представления.'),
paragraph('Третий вариант: source уже private v3, а cache содержит public v2. Это не «подождём пока event дойдёт». Read не должен возвращать значение, потому что изменилось право показа. В модели он evict-ит entry и выдаёт <code>not-visible</code> до broker. Четвёртый вариант: source, key и cache version совпадают, но пользователь всё равно видит старый текст. Тогда кеш — не доказанная причина. Возможно, view собирает другой field, клиент держит локальное состояние или релиз ещё не получил новую сборку. Следующая проверка должна быть на renderer или delivery, а не на случайную очистку key.'),
dataTable(
'Разбор симптома через факт, а не через предположение',
['Наблюдение', 'Вероятная граница', 'Безопасная проверка', 'Следующее действие'],
[
['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 удаляет по любому событию', 'проверить <code>entry.version &lt; event.version</code>', 'оставить current entry и зафиксировать duplicate policy'],
],
),
heading('Проверяем не только version, но и состав проекции'),
paragraph('Версия защищает от старой source записи, но не от случайного поля. В fixture source содержит <code>editorNote</code>, а <code>createPublicProjection()</code> явно возвращает только четыре public field. Assertion проверяет отсутствие <code>editorNote</code> в результатe. Это не полноценная проверка прав пользователя, однако она ловит важный класс ошибок: разработчик добавил поле к source object и сделал spread в cache value, не пересмотрев право публичного чтения.'),
codeBlock([
"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'",
]),
paragraph('Здесь важно не сделать обратную ошибку и не сохранить каждый permission в key автоматически. Key выражает только ту область, которая действительно меняет результат. В учебном public contract достаточно <code>public</code>. Если проект вводит role, locale или tenant, сначала надо показать, как они меняют projection и где являются owner. Иначе cache станет дорогой картой случайных параметров, а утечка всё равно останется в месте, которое никто не назвал.'),
heading('Что из HTTP помогает, а что не переносится'),
paragraph('RFC 7232 описывает validators для HTTP-representation. <code>If-None-Match</code> делает request условным и позволяет origin вернуть <code>304</code>, если entity-tag совпал. Это может быть частью реального HTTP-read path, но не равно нашему <code>sourceVersion</code>. Entity-tag может описывать выбранное представление, а source version — порядок записи владельца. Смешать их в одной переменной удобно только до первого разного reader scope или renderer.'),
paragraph('RFC 7234 также требует invalidation effective request URI при успешном unsafe request, но предупреждает, что другие caches могут остаться с подходящими responses. Поэтому проверка «origin получил POST 200» не доказывает, что браузер, reverse proxy и application cache уже дают одно и то же. Для разрешённой интеграции нужно выбрать один слой, записать его key, validator или purge contract и проверить конкретный response. Этот пакет намеренно до такого шага не доходит.'),
heading('Нумерованный маршрут разбора'),
orderedList([
'Зафиксировать 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 и зафиксировать его отдельные гарантии.',
]),
heading('Граница разбора и следующий шаг'),
paragraph('Этот разбор не подключает 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. Он не означает, что настоящий сервис уже обеспечивает такое свойство.'),
paragraph('После этого разбора остаётся короткий следующий шаг: выбрать один реальный projection и собрать тот же evidence packet без чувствительных данных. Если source version, key, cache version и event нельзя увидеть в одной тестовой истории, сначала добавьте эту наблюдаемость. Тогда следующая статья будет опираться не на яркий purge, а на проверяемый факт, почему конкретный читатель получил именно это представление.'),
],
[rfc7234, rfc7232],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
const fixture = runCacheInvalidationFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('Cache invalidation fixture assertions failed');
}
process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-02.mjs --print-revisions | --verify-fixture\n');
}
}