From e67b416a819661de9b0c65cd972c9e87841bc8fa Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 12:16:22 +0300 Subject: [PATCH] revise February 2021 cache articles --- editorial/production/README.md | 2 +- editorial/reviews/2021-02-draft.md | 165 +++++ web/data/editorial-revisions.mjs | 2 + .../2021/cache-consistency-matrix-2021.svg | 91 +++ .../editorial/2021/cache-diagnosis-2021.svg | 82 +++ .../2021/cache-key-lifecycle-2021.svg | 76 +++ web/scripts/upgrade-2021-02.mjs | 631 ++++++++++++++++++ 7 files changed, 1048 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2021-02-draft.md create mode 100644 web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg create mode 100644 web/public/assets/editorial/2021/cache-diagnosis-2021.svg create mode 100644 web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg create mode 100644 web/scripts/upgrade-2021-02.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index f8dd528..c26f8a8 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 109 из 358 созданных материалов. Остальные 249 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 112 из 358 созданных материалов. Остальные 246 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2021-02-draft.md b/editorial/reviews/2021-02-draft.md new file mode 100644 index 0000000..c397490 --- /dev/null +++ b/editorial/reviews/2021-02-draft.md @@ -0,0 +1,165 @@ +# Автономное тройное ревью П36 · февраль 2021 · «Инвалидация кеша» + +Статус: **принят независимым редактором в выпусковой набор**. В module ровно +три revision для стабильных slug: + +- editorial-2021-02-practice-cache-invalidation; +- editorial-2021-02-mechanism-cache-invalidation; +- editorial-2021-02-field-cache-invalidation. + +Revision не содержат date и author, не подключают +registry и не переписывают базовый архив. В этой авторской партии не менялись +articles.json, 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, но не называет article:public:guide-42 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 не доказывает немедленную очистку каждого слоя. | +| If-None-Match делает request условным и для GET/HEAD может дать 304 Not Modified при совпадении entity-tag | [RFC 7232, section 3.2](https://www.rfc-editor.org/rfc/rfc7232) | sourceVersion модели прямо отделена от 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 + +runCacheInvalidationFixture() создаёт один source object +guide-42 и три последовательные версии только в Map: + +1. public v1 строит article:public:guide-42; +2. public v2 записывается, но ArticleChanged v2 намеренно не + применяется до следующего read; +3. этот read видит cached v1, сравнивает её с source v2 и возвращает rebuilt + public v2; +4. позднее event v2 оставляет уже current entry v2; +5. private v3 делает public read not-visible и удаляет old entry + ещё до delivery v3. + +Проверены восемь assertions: initial v1 build, stale rebuild v2, сохранение +current entry поздним event, v2 hit, отсутствие editorNote в +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 с + caption/thead, figure с самостоятельными + alt/figcaption, минимум два технических примера, + нумерованный маршрут и две ссылки на 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 — пройдено в границах пакета + +- cache-key-lifecycle-2021.svg показывает полный порядок source + v2 → public key → delayed event → version guard → rebuilt public v2. В + первом рендере текст карточек соприкасался со стрелками; карточки были + увеличены, схема отрендерена и просмотрена повторно. +- cache-consistency-matrix-2021.svg сопоставляет пять состояний + source, cache и event с допустимым решением read, включая delayed v2 и + private v3. +- cache-diagnosis-2021.svg ведёт от stale title к evidence + packet и отделяет stale entry, wrong key, private source и renderer вне + кеша. +- У всех SVG есть title, desc, role="img", + вертикальный viewBox, контрастные карточки и короткие строки. В них нет + JavaScript, foreignObject, external URL или raster data URI. +- После Sharp-рендера на ширине 375 px визуально просмотрены три финальные + PNG: 375×719, 375×641 и + 375×646. Clipping, наложения и горизонтальный overflow + внутри SVG не обнаружены. + +### Фактически выполненные проверки + +Команды запускались из web/ после финальной редакторской правки: + +
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
+ +| Проверка | Реальный результат | +| --- | --- | +| node --check | 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 равны true; stale read пересобрал v2, late event v2 сохранил current entry, private v3 не выдан публичному reader | +| xmllint --noout | PASS, все три SVG — корректный XML | +| Sharp mobile preflight | PASS: финальные PNG 375 px просмотрены; первая схема исправлена после первоначального статического рендера | +| Scope/self-review | PASS: revision не меняют date/author; 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. web/scripts/upgrade-2021-02.mjs; +2. editorial/reviews/2021-02-draft.md; +3. web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg; +4. web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg; +5. web/public/assets/editorial/2021/cache-diagnosis-2021.svg. + +## Независимая интеграционная приёмка + +Основной редактор 31 июля 2026 года подключил три revision к +web/data/editorial-revisions.mjs, не меняя базовый +articles.json, даты или автора архивных записей. В registry стало +103 revision. RFC 7234 и RFC 7232 сверены независимо: в феврале 2021 они +задавали HTTP cache semantics, а учебная sourceVersion остаётся +прикладной моделью, не 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 остаётся источником её фактической записи. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 2e60e60..cf1cd94 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -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 december2020Revisions } from '../scripts/upgrade-2020-12.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. export const editorialRevisions = [ @@ -69,4 +70,5 @@ export const editorialRevisions = [ ...november2020Revisions, ...december2020Revisions, ...january2021Revisions, + ...february2021Revisions, ]; diff --git a/web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg b/web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg new file mode 100644 index 0000000..3f497e3 --- /dev/null +++ b/web/public/assets/editorial/2021/cache-consistency-matrix-2021.svg @@ -0,0 +1,91 @@ + + Матрица допустимых решений чтения при инвалидации кеша + Пять строк показывают отношения source версии, cache entry и события. Совпадающие версии дают hit, отставшая cache версия вызывает rebuild, а private source запрещает публичный ответ и очищает entry. + + + + + Матрица: что вправе read + Один owner · одна public projection · учебная модель + + + SOURCE + CACHE + EVENT + READ + RESULT + + + + public v1 + public v1 + нет + hit-current + version равна + key совпал + — + вернуть v1 + + + + public v2 + public v1 + pending v2 + stale-rebuilt + source новее + entry старая + не доставлен + вернуть v2 + + + + public v2 + нет + applied v2 + miss-built + source current + evicted + v1 deleted + собрать v2 + + + + public v2 + public v2 + late v2 + keep hit + version равна + уже rebuilt + v2 duplicate + не delete + + + + private v3 + public v2 + pending v3 + not-visible + право изменилось + недопустима + не ждём + deny + evict + + + TTL может ограничить жизнь entry, но не доказывает current version. + Это не Redis, CDN, browser cache или измерение production traffic. + diff --git a/web/public/assets/editorial/2021/cache-diagnosis-2021.svg b/web/public/assets/editorial/2021/cache-diagnosis-2021.svg new file mode 100644 index 0000000..362f2aa --- /dev/null +++ b/web/public/assets/editorial/2021/cache-diagnosis-2021.svg @@ -0,0 +1,82 @@ + + Диагностика старой кешированной проекции + Вертикальная схема начинает со старой карточки у читателя, собирает source version, key, cache version, event и visibility. Затем она разделяет stale entry, неправильный ключ, private источник и проблему renderer вне кеша. + + + + + + + + Диагноз stale-read + Сначала evidence, потом purge + + + Симптом + source обновлён, reader видит old title + Не удалять key до сохранения фактов. + + + + + Evidence packet + 1. source id и current version + 2. read key и cached version + 3. event state и reader visibility + Один title не доказывает причину. + + + + + Source version выше cached? + Да → key тот же? event pending? + Нет → искать другой read layer. + + + + + + Stale entry + source v2, cache v1 + read: rebuild v2 + late v2: keep current + Не выдавать v1 как hit. + + + Не тот key + scope или locale lost + исправить key contract + не purge correct entry + до проверки collision. + + + + + + Source private + deny + evict before event + Право выше TTL. + + + Versions equal + проверить renderer, client + или другой cache layer. + + Учебная Map-модель · не browser · не cache store · не broker + diff --git a/web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg b/web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg new file mode 100644 index 0000000..8651b34 --- /dev/null +++ b/web/public/assets/editorial/2021/cache-key-lifecycle-2021.svg @@ -0,0 +1,76 @@ + + Учебный жизненный цикл версии кешированной публичной проекции + Вертикальная схема показывает запись source версии два, публичный ключ, запоздавшее событие ArticleChanged версии два, сравнение cache версии один с source версией два и пересборку публичной проекции до ответа читателю. + + + + + + + + Key → version → read + Учебный contract одной public проекции + + + + WRITE + Source owner записал v2 + article guide-42 · visibility public + Только source owner создаёт version. + + + + + + KEY + Reader scope входит в key + article:public:guide-42 + editorNote не входит в public value. + + + + + + EVENT + ArticleChanged v2 запоздало + Cache ещё хранит entry sourceVersion v1. + Delivery не равно праву вернуть v1. + + + + + + READ + Сравнение до cache hit + source v2 ≠ cached v1 → stale, не hit + Собираем projection только из public fields. + + + + + + SAFE + stale-rebuilt → public v2 + Поздний event v2 сохраняет current entry v2. + Если source стал private: deny и evict. + + Map in memory · не Redis · не broker · не CDN + diff --git a/web/scripts/upgrade-2021-02.mjs b/web/scripts/upgrade-2021-02.mjs new file mode 100644 index 0000000..7b1081d --- /dev/null +++ b/web/scripts/upgrade-2021-02.mjs @@ -0,0 +1,631 @@ +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + escapeHtml(caption) + '
'; +} + +function sourceList(items) { + return ''; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText( + content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, ''), + ); +} + +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 вправе вернуть значение. Главный инвариант здесь не «кеш быстрый», а «читатель получает только данные, которые вправе видеть в текущем состоянии источника». Ниже все значения синтетические и живут в Map; они объясняют порядок, но не изображают работающую инфраструктуру.'), + heading('Сначала определяем границу чтения'), + paragraph('Кеширует не таблица и не объект целиком, а конкретный ответ на конкретный вопрос. В упражнении таким вопросом будет «что может увидеть публичный читатель у article guide-42?». Исходная запись принадлежит одному source owner. У неё есть visibility, title, внутренняя заметка редактора и монотонная version. Публичная проекция содержит только id, title, version и область читателя. Внутренняя заметка не должна попасть в неё даже при cache hit.'), + dataTable( + 'Контракт одной публичной карточки', + ['Часть', 'Владелец или значение', 'Почему нужна', 'Проверка'], + [ + ['Источник', 'article:guide-42 у учебного source owner', 'только он создаёт следующую версию', 'после write version растёт с 1 до 2'], + ['Read key', 'article:public:guide-42', 'key отделяет public projection от другой области чтения', 'reader scope явно виден в key'], + ['Проекция', 'id, title, sourceVersion', 'read не переносит редакторское поле по привычке', 'в object нет editorNote'], + ['Событие', 'ArticleChanged с id и sourceVersion', 'сообщает, для какой версии прежняя 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 выглядит удобно: article:guide-42. Он быстро строится, но ничего не говорит, какое представление там лежит. Если одна ветка кода записала публичную карточку, а другая позднее ожидает редакторскую, 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 создаёт ArticleChanged, но не делает вид, что это уже доставка через 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 сравнивает entry.sourceVersion с record.version. Несовпадение не считается 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, возвращает stale-rebuilt с публичной проекцией v2. Когда запоздавшее event приходит позже, оно не удаляет уже current entry v2. В конце source делает запись private v3: public read обязан отказаться от выдачи и удалить предыдущую public entry ещё до delivery события.'), + heading('Нумерованный маршрут для одного контракта'), + orderedList([ + 'Назвать один читательский вопрос и цену stale ответа. Не начинать с общего «почистим кеш».', + 'Назначить source owner: именно он создаёт следующую version и определяет visibility записи.', + 'Собрать key из объекта и тех условий, которые меняют право увидеть проекцию. Для public карточки сохранить область public в 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 и не даёт нам право назвать любую Map HTTP cache. Она даёт полезный язык: reuse разрешён не просто потому, что есть значение, а при соблюдении ключа, свежести или validation. RFC 7232 дополняет его validators: If-None-Match позволяет проверять представление через entity-tag и получать 304 Not Modified. В нашем коде sourceVersion — не 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', 'нет', '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 и ничего не показать'], + ], + ), + 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 только при entry.sourceVersion < event.sourceVersion. Равная 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 делает только cache.get(key), он выдаёт stale. Если read сравнивает versions, он видит 1 !== 2, строит новую public projection и заменяет entry. Именно это fixture называет stale-rebuilt. Результат не равен HTTP validation и не доказывает, что реальный source storage доступен так же быстро; он показывает явный выбор нашего контракта.'), + codeBlock(guardedReadCode), + paragraph('Равенство version ещё не достаточно без visibility. Если source v3 стал private, entry v2 может совпадать с последней известной cache version, но уже нарушает правило выдачи. Поэтому пример проверяет record.visibility до cache hit, удаляет public key и возвращает not-visible. Полезная мелочь: 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 тоже имеет цену. В нашей Map 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 можно объяснить парой key + sourceVersion и текущим правом читателя. Если в логе или 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 guide-42. В нём 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', '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 всего приложения проверена'], + ], + ), + 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 и возвращает stale-rebuilt. Потом delivery v2 видит entry v2 и отвечает kept-current-entry. Это важная проверка против «очищать по каждому 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 и выдаёт not-visible до 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 удаляет по любому событию', 'проверить entry.version < event.version', 'оставить current entry и зафиксировать duplicate policy'], + ], + ), + heading('Проверяем не только version, но и состав проекции'), + paragraph('Версия защищает от старой source записи, но не от случайного поля. В fixture source содержит editorNote, а createPublicProjection() явно возвращает только четыре public field. Assertion проверяет отсутствие editorNote в результат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 достаточно public. Если проект вводит role, locale или tenant, сначала надо показать, как они меняют projection и где являются owner. Иначе cache станет дорогой картой случайных параметров, а утечка всё равно останется в месте, которое никто не назвал.'), + heading('Что из HTTP помогает, а что не переносится'), + paragraph('RFC 7232 описывает validators для HTTP-representation. If-None-Match делает request условным и позволяет origin вернуть 304, если entity-tag совпал. Это может быть частью реального HTTP-read path, но не равно нашему sourceVersion. 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'); + } +}