Тема
ТЗ — 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_log | id, provider, urls (json), status, attempts, error, created_at | журнал batch-запросов на очистку |
cms_cdn_settings | id, 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: bool | FormRequest: 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/CdnPurgeCompleted | payload события (см. «События и обмен») |
cms/health / GET /api/v1/system/health | статус доступности провайдера, отставание очереди cdn | агрегированный health-чек JSON |
Filament / GET /api/v1/admin/cdn/log | журнал purge-запросов | конверт {data, meta}, keyset-пагинация |
| CLI/CI | cms:cdn:status --json | статус модуля, провайдера, глубина очереди |
Модуль-потребитель cdn-provider | результат вызова purge() | подтверждение постановки в очередь/отклонение по квоте — не результат самого HTTP-purge (он асинхронный, см. «Фоновая работа») |
Настройки (группа cdn)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
cdn.enabled | bool | false | нет | Глобальный включатель модуля |
cdn.provider | string | bunny | нет | Активный провайдер (bunny/cloudflare) |
cdn.batch_window_seconds | int | 10 | нет | Окно группировки URL перед отправкой batch-purge |
cdn.batch_max_urls | int | 100 | нет | Максимум URL в одном batch-запросе |
cdn.retry_attempts | int | 3 | нет | Число ретраев при ошибке API |
cdn.zone_id | string | — | нет | Идентификатор зоны у провайдера (не секрет) |
cdn.allowed_domains | array | домены текущего сайта | нет | Whitelist доменов, для которых разрешён purge — защита от чужих/произвольных URL |
cdn.purge_confirmation_required | bool | true | нет | Требовать текстовое подтверждение при полной очистке зоны |
cdn.log_retention_days | int | 30 | нет | Срок хранения журнала purge-запросов до автоочистки |
cdn.health_check_interval_minutes | int | 5 | нет | Периодичность health-чека доступности провайдера |
cdn.purge_on_theme_change | bool | true | нет | Массовый purge зоны при ThemeChanged (смена темы меняет разметку всех страниц) |
cdn.monthly_request_quota | int, nullable | null | нет | Тарифный лимит purge-запросов/мес у провайдера (для видимости расхода в админке, см. «Крайние случаи») |
cdn.quota_alert_threshold_percent | int | 80 | нет | Порог расхода квоты, после которого — алерт через cms/health |
cdn.auto_purge_paused | bool | false | нет | Kill-switch (матрица v2.2): приостановка автоматического purge по событиям без выключения модуля — ручной purge из Filament/API остаётся доступен |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/admin/cdn/purge | admin (cdn.manage) | Точечная или полная очистка зоны |
| GET | /api/v1/admin/cdn/log | admin (cdn.view) | Журнал purge-запросов |
| POST | /api/v1/admin/cdn/log/{id}/retry | admin (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 поставлен в очередь на purge | urls, source_model |
CdnPurgeCompleted | batch-запрос обработан провайдером | provider, urls, status |
Слушает: события публикации/обновления/удаления контента из ядра и модулей (страницы, каталог, медиа). Provides: cdn-provider — контракт для делегирования purge модулями контента без прямого обращения к API провайдера.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
core: страницы (PageSaved/PagePublished, поле previous_slug) | событие (канал 1) | in | URL страницы ставится в очередь batch-purge; при смене адреса — purge и старого, и нового URL одним батчем (ревизия ядра 14.07.2026, п.5) |
core: типы контента (ContentEntrySaved, поле previous_slug) | событие (канал 1) | in | То же для записей типов контента при смене slug |
core: удаление (PageDeleted/ContentEntryDeleted/MediaDeleted, ревизия 14.07.2026, п.3) | событие (канал 1) | in | Purge адреса удалённой сущности — без похода в репозиторий, сущности уже нет |
core: медиа (MediaUploaded) | событие (канал 1) | in | purge URL файла при перезаписи по тому же пути |
core: меню/виджеты/темы (MenuSaved, WidgetSaved/Deleted, ThemeChanged) | событие (канал 1) | in | purge связанных статических фрагментов/ассетов; ThemeChanged — массовый purge зоны при cdn.purge_on_theme_change=true |
core: настройки (SettingChanged, группа cdn) | событие (канал 1) | in | модуль перечитывает провайдера/лимиты (кеш настроек сброшен ядром) |
cms/commerce-catalog, модули контента | provides-контракт cdn-provider (канал 3) | in | модуль вызывает CdnPurgeInterface::purge() напрямую, минуя ожидание события |
cms/health | health-чек / сервис-вызов (канал 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 в .env | cms:cdn:status --json |
Очередь cdn растёт, purge отстаёт | глубина очереди, ретраи с backoff | cms:cdn:status --json, журнал failed-записей |
| Устаревший контент отдаётся из CDN | последняя запись журнала для URL, TTL записи в CDN | cms:cdn:purge --json --url=... |
| Расход квоты провайдера приближается к лимиту | cdn.monthly_request_quota vs фактический расход | индикатор в Filament, алерт cms/health |
| После смены темы старая разметка ещё видна | сработал ли массовый purge по ThemeChanged | cms: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/MediaDeletedpurge при удалении было недостижимо штатно (репозиторий уже не находит удалённую запись, чтобы резолвить её 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, интерфейс purge | catalog/src/app/Services/Cdn/ |
| Оркестрация batch-purge | catalog/src/app/Services/CdnPurgeService.php |
| Инвалидация локального кеша совместно с CDN | catalog/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запрещён