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'); } }