Skip to content

ТЗ — CDN-инвалидация (cms/cdn)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★★★ · Донор: catalog (catalog/src/app/Services/Cdn/, CdnPurgeService.php, CacheService.php) Статус: ТЗ к разработке

Назначение и возможности

Инвалидация кеша CDN при изменении контента: подписка на события публикации/обновления сущностей, батчевая отправка purge-запросов провайдеру (не лавиной по одному URL на событие). Единый контракт для переключения между провайдерами.

  • Драйверы Bunny и Cloudflare через общий интерфейс CdnPurgeInterface
  • Батчевая очистка (группировка URL за окно времени, лимиты API провайдера)
  • Purge по событиям контента ядра и модулей (страницы, каталог, медиа), включая purge старого и нового URL при смене адреса (previous_slug) и purge по событиям удаления
  • Массовая очистка зоны при смене темы оформления (затрагивает разметку всех страниц разом)
  • Ручная точечная очистка конкретного URL из Filament
  • Полная очистка зоны как аварийная кнопка (с подтверждением)
  • Ретраи с backoff при ошибке API провайдера
  • Журнал purge-запросов с результатом (успех/ошибка/провайдер)
  • Ручной повтор конкретного неудавшегося purge-запроса из журнала
  • Видимость тарифного расхода (запросы/трафик) провайдера и деградация без 500 при исчерпании квоты
  • Аварийная приостановка автоматического purge по событиям без выключения модуля целиком

Зависимости и выключение

requires: ядро · provides: cdn-provider

Поведение при выключении: события публикации контента больше не триггерят purge — отдающийся из CDN контент устаревает до истечения его собственного TTL. Приложение и публикация контента работают штатно.

Модель данных

ТаблицаКлючевые поляПримечание
cms_cdn_purge_logid, provider, urls (json), status, attempts, error, created_atжурнал batch-запросов на очистку
cms_cdn_settingsid, provider, credentials_ref, zoneссылки на креды в .env, не сами секреты

Заметки: status — PHP Enum; urls — JSON с касом 'array' (короткоживущий журнал, B-tree по id/created_at); cms_cdn_purge_log — кандидат на BRIN-индекс по created_at при росте объёма.

Входные и выходные данные

Входы

ИсточникДанные/поляЧем валидируется
Событие PageSaved/PagePublished (ядро)id, slug, url, previous_slug (nullable, ревизия ядра 14.07.2026, п.5) затронутой страницыподписчик резолвит URL через PageRepository, не доверяет сырому payload напрямую
Событие ContentEntrySaved (ядро/модули контента)id, slug, previous_slug (nullable), тип записирезолвится в URL через ContentRepository по маршруту типа
События PageDeleted/ContentEntryDeleted/MediaDeleted (ядро, ревизия 14.07.2026, п.3)id, slug/путь удалённой сущностиpayload содержит адрес на момент удаления — purge без похода в репозиторий (сущности уже нет)
Событие MediaUploaded (ядро)id, путь файлаpurge только если файл перезаписан по тому же пути (не на каждую загрузку)
События MenuSaved/WidgetSaved/WidgetDeleted/ThemeChanged (ядро)id области/темытриггерит purge связанных статических фрагментов/ассетов; ThemeChanged — массовый purge всей зоны (см. «Крайние случаи»)
POST /api/v1/admin/cdn/purge (Filament/API)urls: string[] или zone_purge_all: boolFormRequest: whitelist полей, каждый URL — на домене сайта (см. cdn.allowed_domains), не более cdn.batch_max_urls, право cdn.manage
POST /api/v1/admin/cdn/log/{id}/retry (Filament/API)id записи журналаправо cdn.manage, запись должна быть в статусе failed
GET/PUT /api/v1/admin/settings/cdn (settings-store ядра)ключи группы cdn (провайдер, лимиты, ретраи)схема настроек (тип/дефолт/валидация), право settings.manage
Вызов через cdn-provider (канал 3) от модуля-потребителя (каталог/медиа/контент)urls: string[]вызывающий модуль сам резолвит корректные URL своего домена; cdn дополнительно проверяет лимит батча и whitelist доменов

Примечание: всё, что не перечислено выше как вход, модуль обязан отвергать — purge по произвольному URL вне cdn.allowed_domains запрещён (422), неизвестное поле в API-запросе не игнорируется, а возвращает 422.

Выходы

ПотребительДанныеФормат
Модули-подписчики / cms/audit (если включен)события CdnPurgeRequested/CdnPurgeCompletedpayload события (см. «События и обмен»)
cms/health / GET /api/v1/system/healthстатус доступности провайдера, отставание очереди cdnагрегированный health-чек JSON
Filament / GET /api/v1/admin/cdn/logжурнал purge-запросовконверт {data, meta}, keyset-пагинация
CLI/CIcms:cdn:status --jsonстатус модуля, провайдера, глубина очереди
Модуль-потребитель cdn-providerрезультат вызова purge()подтверждение постановки в очередь/отклонение по квоте — не результат самого HTTP-purge (он асинхронный, см. «Фоновая работа»)

Настройки (группа cdn)

КлючТипДефолтaffectsPageCacheОписание
cdn.enabledboolfalseнетГлобальный включатель модуля
cdn.providerstringbunnyнетАктивный провайдер (bunny/cloudflare)
cdn.batch_window_secondsint10нетОкно группировки URL перед отправкой batch-purge
cdn.batch_max_urlsint100нетМаксимум URL в одном batch-запросе
cdn.retry_attemptsint3нетЧисло ретраев при ошибке API
cdn.zone_idstringнетИдентификатор зоны у провайдера (не секрет)
cdn.allowed_domainsarrayдомены текущего сайтанетWhitelist доменов, для которых разрешён purge — защита от чужих/произвольных URL
cdn.purge_confirmation_requiredbooltrueнетТребовать текстовое подтверждение при полной очистке зоны
cdn.log_retention_daysint30нетСрок хранения журнала purge-запросов до автоочистки
cdn.health_check_interval_minutesint5нетПериодичность health-чека доступности провайдера
cdn.purge_on_theme_changebooltrueнетМассовый purge зоны при ThemeChanged (смена темы меняет разметку всех страниц)
cdn.monthly_request_quotaint, nullablenullнетТарифный лимит purge-запросов/мес у провайдера (для видимости расхода в админке, см. «Крайние случаи»)
cdn.quota_alert_threshold_percentint80нетПорог расхода квоты, после которого — алерт через cms/health
cdn.auto_purge_pausedboolfalseнетKill-switch (матрица v2.2): приостановка автоматического purge по событиям без выключения модуля — ручной purge из Filament/API остаётся доступен

API

МетодПутьДоступНазначение
POST/api/v1/admin/cdn/purgeadmin (cdn.manage)Точечная или полная очистка зоны
GET/api/v1/admin/cdn/logadmin (cdn.view)Журнал purge-запросов
POST/api/v1/admin/cdn/log/{id}/retryadmin (cdn.manage)Повтор конкретного неудавшегося purge-запроса

Журнал отдаётся keyset-пагинацией (не OFFSET).

Компоненты

Filament: страница журнала purge (с массовым действием «Повторить выбранные» на отмеченных failed-записях), кнопка «Очистить зону» с текстовым подтверждением, индикатор расхода тарифной квоты провайдера (cdn.monthly_request_quota). Команды: cms:cdn:purge --json, cms:cdn:status --json. Демо-контент (матрица v2.2): не применимо — публичных блоков/виджетов нет, admin-only инструмент; журнал наполняется собственными purge-запросами сразу после первой публикации контента.

События и обмен

СобытиеКогдаPayload
CdnPurgeRequestedконтент опубликован/обновлён/удалён, URL поставлен в очередь на purgeurls, source_model
CdnPurgeCompletedbatch-запрос обработан провайдеромprovider, urls, status

Слушает: события публикации/обновления/удаления контента из ядра и модулей (страницы, каталог, медиа). Provides: cdn-provider — контракт для делегирования purge модулями контента без прямого обращения к API провайдера.

Таблица взаимодействий

Сущность/модульКаналНаправлениеЧто происходит
core: страницы (PageSaved/PagePublished, поле previous_slug)событие (канал 1)inURL страницы ставится в очередь batch-purge; при смене адреса — purge и старого, и нового URL одним батчем (ревизия ядра 14.07.2026, п.5)
core: типы контента (ContentEntrySaved, поле previous_slug)событие (канал 1)inТо же для записей типов контента при смене slug
core: удаление (PageDeleted/ContentEntryDeleted/MediaDeleted, ревизия 14.07.2026, п.3)событие (канал 1)inPurge адреса удалённой сущности — без похода в репозиторий, сущности уже нет
core: медиа (MediaUploaded)событие (канал 1)inpurge URL файла при перезаписи по тому же пути
core: меню/виджеты/темы (MenuSaved, WidgetSaved/Deleted, ThemeChanged)событие (канал 1)inpurge связанных статических фрагментов/ассетов; ThemeChanged — массовый purge зоны при cdn.purge_on_theme_change=true
core: настройки (SettingChanged, группа cdn)событие (канал 1)inмодуль перечитывает провайдера/лимиты (кеш настроек сброшен ядром)
cms/commerce-catalog, модули контентаprovides-контракт cdn-provider (канал 3)inмодуль вызывает CdnPurgeInterface::purge() напрямую, минуя ожидание события
cms/healthhealth-чек / сервис-вызов (канал 4)outстатус доступности провайдера и отставание очереди cdn в /api/v1/system/health
cms/audit (если включен)событие CdnPurgeRequested/CdnPurgeCompleted (канал 1)outзапись ручных и автоматических purge-операций в аудит-журнал

Любая связь вне этой таблицы — скрытая зависимость и анти-паттерн.

Фоновая работа

Очередь cdn для batch-purge: события группируются в окне cdn.batch_window_seconds, один batch-запрос уходит в очередь; ретраи с backoff — на уровне джобы. HTTP-вызовы к API провайдера — только из очереди, никогда синхронно в обработчике события или в вызове cdn-provider.

Мини-ранбук (§15 стандарта):

СимптомЧто проверитьКоманда
Purge не доходит до провайдерадоступность API, credentials_ref в .envcms:cdn:status --json
Очередь cdn растёт, purge отстаётглубина очереди, ретраи с backoffcms:cdn:status --json, журнал failed-записей
Устаревший контент отдаётся из CDNпоследняя запись журнала для URL, TTL записи в CDNcms:cdn:purge --json --url=...
Расход квоты провайдера приближается к лимитуcdn.monthly_request_quota vs фактический расходиндикатор в Filament, алерт cms/health
После смены темы старая разметка ещё виднасработал ли массовый purge по ThemeChangedcms:cdn:purge --json --zone

Производительность и кеш

  • Ожидаемые объёмы: managed-парк студии — десятки-сотни сайтов; на публикацию/обновление — 1–5 URL к purge; суммарный поток по парку — сотни-первые тысячи URL/сутки, десятки batch-purge запросов/сутки на провайдера (пики — при массовых деплоях/рассылках).
  • Горячие пути и бюджет: обработчик события — только запись в лог + постановка в очередь, 0 синхронных HTTP-запросов; сам HTTP purge — из очереди, бюджет 1 запрос к провайдеру на весь batch, не на URL.
  • Индексы: cms_cdn_purge_log — BRIN по created_at (append-only, растёт быстро), B-tree по status (выборка failed для ручного повтора и для health-чека), составной (provider, created_at) для журнала по конкретному провайдеру.
  • Что кешируется: настройки группы cdn — из кеша settings-store ядра (0 запросов на горячем пути записи события). Сам контент/страницы модуль не кеширует — он инструмент инвалидации внешнего (CDN) кеша, а не источник данных.
  • Теги и инвалидация: собственных тегов кеша не объявляет. Событие CdnPurgeCompleted не инвалидирует локальный page-cache само по себе — это делает исходный модуль-издатель своими тегами; cdn отвечает только за внешний (CDN) кеш.

Безопасность

Креды провайдера (credentials_ref) — только ссылки на .env, секреты в БД и журнале запрещены; ручная точечная/полная очистка — только под cdn.manage с подтверждением на фронте. Rate-limit purge ограничен cdn.batch_max_urls за окно — защита от лавины при массовых публикациях.

Векторы атак, специфичные для модуля:

  • Purge чужого URL — форма ручной очистки принимает произвольный URL; без whitelist это позволяет дёргать purge по адресам вне зоны сайта (в лучшем случае бесполезно, в худшем — расход лимита API провайдера/DoS чужой зоны). Защита — cdn.allowed_domains, каждый URL проверяется на принадлежность домену сайта до постановки в очередь.
  • Расход бюджета провайдера как DoS — спам ручных point-purge запросов исчерпывает дневную квоту API провайдера. Защита — rate-limit per-токен на POST /cdn/purge поверх cdn.batch_max_urls.
  • Утечка кредов через журнал/логиcms_cdn_purge_log и логи очереди не должны содержать credentials_ref-значение целиком или сам секрет; хранится и логируется только ссылка на .env-ключ.
  • SSRF через payload события — URL для purge строится из события, а не берётся напрямую из пользовательского ввода без резолва (см. «Входы») — модуль не должен слепо доверять произвольной строке url из чужого payload и обязан резолвить её через репозиторий/сервис владельца данных, а не делать HTTP-запрос по любому URL, полученному в событии.
  • Приватные (подписанные) URL медиатеки/storage-s3 не должны кешироваться публично CDN — origin обязан отдавать такие ответы с Cache-Control: private, no-store (или CDN настроен не кешировать по префиксу пути приватных файлов); cms/cdn не полагается на то, что сама подпись URL защитит объект в CDN-кеше — деталь и контрактный тест см. «Крайние случаи».

ПДн-паспорт (матрица v2.2). Собственные таблицы (cms_cdn_purge_log, cms_cdn_settings) ПДн не хранят — urls в журнале это публичные адреса страниц/медиа, не персональные данные; credentials_ref — ссылка на .env, не сам секрет. Модуль не участвует в «выгрузить/забыть по запросу» — ему нечего выгружать по субъекту.

Матрица ролей:

РольПросмотр журнала/квотыТочечный purgeПолная очистка зоны
studio
админ✅ (с подтверждением)
менеджер
редактор

Права: cdn.view, cdn.manage.

UX-требования

Админ:

  • Пустое состояние журнала — «Здесь появятся запросы после первой публикации контента. Чтобы очистить кеш сейчас — кнопка «Очистить зону» справа».
  • Массовое действие в журнале: чекбоксы на failed-записях + кнопка «Повторить выбранные» — ставит выбранные purge-запросы в очередь заново одним batch, без похода по одному.
  • Человеческие ошибки: вместо трассировки исключения — «Провайдер не ответил (таймаут). Повтор запланирован автоматически через N минут. Если повторяется — проверьте креды в .env».
  • Подтверждение необратимых операций: полная очистка зоны — модальное окно с явным текстовым подтверждением (ввод имени зоны или чекбокс «понимаю, что кеш всей зоны будет сброшен») и предупреждением о возможной кратковременной повышенной нагрузке на origin в первые минуты после очистки; точечная очистка одного URL — обычное подтверждение без ввода текста.

Посетитель: прямого взаимодействия с админкой модуля нет, но есть косвенное влияние — скорость отдачи и актуальность контента. При штатной работе задержка актуализации после публикации ограничена cdn.batch_window_seconds и обычно незаметна; при сбое purge (см. «Крайние случаи») посетитель может получать устаревший контент вплоть до истечения TTL записи в CDN — это единственный видимый посетителю эффект модуля.

Крайние случаи и типовые баги

  • Двойной сабмит ручной точечной очистки → второй запрос в течение cdn.batch_window_seconds для того же URL объединяется в тот же batch (идемпотентность по паре provider, url в окне), не создаёт вторую запись в журнале.
  • Параллельные batch-джобы для одной зоны → сериализуются через lock на (provider, zone_id), чтобы не превысить rate-limit провайдера одновременными запросами из разных воркеров очереди.
  • Выключение модуля посреди отправки batch-запроса → уже поставленная в очередь до выключения джоба доводится до конца (не обрывается на полпути); новые события после выключения purge не создают.
  • Инвалидация не прошла после исчерпания ретраев → устаревший контент отдаётся из CDN до истечения TTL; запись в журнале получает статус failed с текстом ошибки; health-чек фиксирует деградацию и алертит через cms/health; админ видит запись в журнале и может запустить POST /cdn/log/{id}/retry вручную без пересборки списка URL заново.
  • Приватный файл уходит в публичный CDN-кеш — реальный риск утечки: если модуль медиа/ storage-s3 отдаёт приватный файл по подписанному (signed) временному URL, а перед origin стоит CDN, кеширующий ответы по пути без учёта query-параметра подписи, один и тот же путь может быть закеширован публично после первого обращения по валидной подписи и отдан второму посетителю уже без проверки TTL/подписи. Ожидаемое поведение: purge/провайдер настраивается так, чтобы не кешировать ответы с приватных путей вовсе (правило по префиксу пути или заголовку Cache-Control: private, no-store от origin, который CDN обязан уважать), а не полагаться на то, что подписанный URL сам себя защитит на уровне CDN-кеша.
  • Отсутствие измерения site_id (мультисайт) → purge должен строиться из полного абсолютного URL с доменом конкретного сайта, не из относительного пути — иначе purge для одного сайта может (в зависимости от конфигурации зоны провайдера) не затронуть или ошибочно затронуть страницу с тем же путём на другом сайте того же managed-парка.
  • Огромный batch (например, полная переиндексация каталога) → превышает cdn.batch_max_urls, разбивается на несколько последовательных batch-запросов с уважением cdn.retry_attempts и rate-limit провайдера, не уходит одним запросом с тысячами URL.
  • Пустые/невалидные данные в событии → модель, вызвавшая событие, к моменту обработки джобы уже удалена или URL не резолвится → purge для этой записи пропускается с warning в логе, джоба не падает и продолжает обрабатывать остальные URL batch'а.
  • Противоречивая комбинация настроек (cdn.enabled = true, но credentials_ref не сконфигурирован) → health-чек красный сразу при enable (self-test из §3 стандарта), purge-джоба фейлится с понятной ошибкой «провайдер не сконфигурирован» до похода в HTTP, не падает необработанным исключением.
  • Смена провайдера с незавершёнными джобами старого → джобы, уже поставленные в очередь до смены cdn.provider, доигрываются под провайдером, зафиксированным в них на момент постановки (снапшот, не «текущая» настройка), — иначе purge может уйти не тому API.
  • Purge старого URL при смене slug — зафиксированное решение (ревизия ядра 14.07.2026, п.5). Смена slug/URL страницы требует purge и старого, и нового адреса; исходная версия ТЗ отмечала, что канонические события ядра не декларировали поле для старого URL — модуль не мог узнать его из одного лишь события. Разрешение: PagePublished и ContentEntrySaved несут previous_slug (nullable) в payload — при непустом значении cdn ставит в batch-purge оба адреса одним событием, без похода в отдельный модуль редиректов и без самодельного диффа состояний.
  • Purge удалённой сущности — зафиксированное решение (ревизия ядра 14.07.2026, п.3). До появления событий PageDeleted/ContentEntryDeleted/MediaDeleted purge при удалении было недостижимо штатно (репозиторий уже не находит удалённую запись, чтобы резолвить её URL) — теперь адрес приходит в payload самого события удаления, purge ставится в очередь так же, как при публикации.
  • Смена темы оформления (ThemeChanged) → при cdn.purge_on_theme_change=true — массовый purge всей зоны одним запросом (не по URL каждой страницы отдельно — тема меняет общую разметку/ ассеты, а не контент конкретных страниц), предупреждение в UI о временно повышенной нагрузке на origin аналогично ручной полной очистке; при false — устаревшая разметка отдаётся до истечения TTL, риск явно принят администратором отключением настройки.
  • Исчерпание тарифной квоты провайдера (cdn.monthly_request_quota) → при достижении cdn.quota_alert_threshold_percent — алерт через cms/health заранее, до полного исчерпания; при фактическом исчерпании — провайдер сам отклоняет запросы (429/402 от API), джоба фиксирует failed с понятной причиной «квота провайдера исчерпана», не молчаливо теряет purge-запросы; деградация — устаревший контент отдаётся до продления квоты/окончания периода, не 500 у сайта.

Донорский код

⚠️ Проект catalog ещё не перенесён на этот сервер (остался на Windows-сервере) — пути ниже станут доступны после переноса.

Что взятьПуть
Драйверы Bunny/Cloudflare, интерфейс purgecatalog/src/app/Services/Cdn/
Оркестрация batch-purgecatalog/src/app/Services/CdnPurgeService.php
Инвалидация локального кеша совместно с CDNcatalog/src/app/Services/CacheService.php

Миграция legacy-данных (§16 стандарта, матрица v2.2): не применимо — журнал purge-запросов не переносится с донора (у истории очистки нет ценности вне контекста старой инсталляции), настройки провайдера/зоны заводятся заново под новую конфигурацию хостинга.

Тесты и приёмка

  • [ ] Контрактный тест: batch-purge не превышает cdn.batch_max_urls за один запрос
  • [ ] Health-чек модуля проверяет доступность API выбранного провайдера
  • [ ] При выключении модуля публикация контента не падает, purge просто не выполняется
  • [ ] Ретраи с backoff покрыты тестом на временную ошибку API
  • [ ] Права cdn.view/cdn.manage разграничивают журнал и ручную очистку
  • [ ] Нет лавины запросов: события за cdn.batch_window_seconds группируются в один batch
  • [ ] Purge по URL вне cdn.allowed_domains отклоняется 422, лимит API провайдера не расходуется
  • [ ] Ручной повтор POST /cdn/log/{id}/retry работает только для записей в статусе failed
  • [ ] Двойной сабмит точечной очистки не создаёт вторую запись в журнале за одно окно
  • [ ] Параллельные batch-джобы одной зоны сериализуются (lock по provider, zone_id)
  • [ ] Приватные пути (signed URL медиатеки/storage-s3) не попадают в публичный CDN-кеш (контрактный тест на заголовки Cache-Control/правило исключения по префиксу)
  • [ ] Смена cdn.provider не переключает уже поставленные в очередь джобы на новый провайдер
  • [ ] Смена slug страницы ставит в batch-purge и старый URL (previous_slug), и новый, одним событием
  • [ ] Удаление страницы/записи/медиа (PageDeleted/ContentEntryDeleted/MediaDeleted) triggерит purge адреса без похода в репозиторий
  • [ ] ThemeChanged при cdn.purge_on_theme_change=true даёт один массовый purge зоны, не по URL каждой страницы
  • [ ] Исчерпание cdn.monthly_request_quota алертит заранее по quota_alert_threshold_percent, деградация без 500
  • [ ] cdn.auto_purge_paused=true останавливает автоматический purge по событиям, ручной purge из Filament/API остаётся доступен
  • [ ] Контрактный набор cms-testing пройден, пакет протестирован в testbench-изоляции
  • [ ] Feature-тест на каждый роут модуля; тестовая БД только cdn_test, migrate:fresh запрещён

Внутренняя база знаний студии