# Кэш

## Главное правило

**`Cache::tags()` в коде быть не должно.**

Автономная копия сайта работает на обычном хостинге, где нет Redis: кэш там
файловый, а файловый драйвер Laravel теги не поддерживает. Это ограничение
первого дня, а не последующая доработка — поэтому инвалидация построена на
счётчиках версий, которые работают на любом драйвере.

Юнит-тест `SiteCacheTest` проверяет отсутствие `->tags(` в исходнике `SiteCache`
и фиксирует это как контракт, а не как договорённость.

## Как это устроено

Ключ выглядит так:

```
kz:{site_id}:{ident}:v{номер версии}
```

Номер версии берётся из счётчика. Чтобы инвалидировать раздел кэша, счётчик
инкрементируется — все ключи со старым номером мгновенно перестают
использоваться и истекают сами. Точечных `Cache::forget` по коду нет.

### Счётчики

| Счётчик | Что покрывает |
|---|---|
| `sv` | настройки сайта |
| `tv` | дерево разделов и всё производное: меню, крошки, SEO |
| `lv` | зоны, блоки, правила видимости, payload блоков |
| `cv` | данные каталога: товары, цены, остатки, фасеты |
| `rv` | редиректы |
| `kv` | типы страниц, собранные в конструкторе админки |
| `gv` | города сайта |

Все читаются **одним обращением** к хранилищу (`Cache::many()` → `MGET`
в Redis) и переиспользуются всеми ключами запроса.

## API

```php
$cache = app(SiteCache::class);

// чтение с запоминанием
$cache->remember($siteId, 'tree', CacheVersion::Tree, ttl: null, fn () => …);

// то же, но с защитой от лавины: при промахе пересобирает один процесс,
// остальные ждут результат
$cache->rememberGuarded($siteId, 'layout', CacheVersion::Layout, null, fn () => …);

// глобальный ключ вне пространства имён сайта
$cache->rememberGlobal('host:alpha.test', 3600, fn () => …);

// инвалидация
$cache->bump($siteId, CacheVersion::Tree);   // один раздел
$cache->bumpAll($siteId);                    // весь кэш сайта
```

`ttl: null` означает «бессрочно» — до инкремента счётчика.

## Кто что кэширует

| Данные | Ключ | TTL | Инвалидация |
|---|---|---|---|
| хост → сайт | `kz:host:{host}` | 1 ч | наблюдатель домена, точечно |
| настройки сайта | `kz:{s}:settings:v{sv}` | ∞ | `INCR sv` при сохранении настройки |
| дерево разделов | `kz:{s}:tree:v{tv}` | ∞ | `INCR tv` из `SectionWriter` |
| редиректы | `kz:{s}:redirects:v{rv}` | ∞ | `INCR rv` из `RedirectWriter` |
| раскладка сайта | `kz:{s}:layout:v{lv}` | ∞ | `INCR lv` из наблюдателя и `LayoutWriter` |
| шаблон карточки | `kz:{s}:layout:{id}:v{lv}` | ∞ | `lv` |
| шаблоны карточки компонента | `kz:{s}:card-layouts:{компонент}:v{lv}` | ∞ | `lv` |
| payload блока | `kz:{s}:block:{id}:{section}:{city}:v{lv}` | `blocks.cache_ttl` | `lv` + TTL |
| payload блока карточки | `kz:{s}:block:{id}:{section}:{city}:o{объект}:v{lv}` | `blocks.cache_ttl` | `lv` + TTL |
| типы страниц из конструктора | `kz:{s}:components:v{kv}` | ∞ | `INCR kv` из наблюдателя определений и полей |
| города сайта | `kz:{s}:cities:v{gv}` | ∞ | `INCR gv` + `lv` из наблюдателя города |
| число товаров под фильтром | `kz:{s}:catalog:count:{hash}:v{cv}` | ∞ | `INCR cv` |
| словарь слагов фильтров | `kz:{s}:catalog:filter-slugs:v{cv}` | ∞ | `INCR cv` |

Счётчик городов двигает и `lv`: город решает видимость блоков, и правило
«показывать в Москве» иначе осталось бы посчитанным по старому списку.

**Свой CSS сайта** в серверный кэш не кладётся вовсе: он собирается из настроек,
которые и так лежат в кэше. Кэширует его **браузер** — адрес
`/kz-site.css?v={хэш содержимого}` отдаётся с `immutable` на год, устаревшая
версия — с `no-cache` ([layout.md](layout.md#свой-css-сайта)).

⚠️ **Город в ключе блока** — от него зависят цены каталога и баннеры
с геотаргетингом: без него первый зашедший москвич раздавал бы московские цены
всей стране на весь TTL блока.

⚠️ **Объект — только в ключе блока карточки.** Раздел у всей витрины один,
и без объекта первый открытый товар раздавал бы свою цену и название остальным.
Блокам самой страницы объект в ключ не идёт: сто тысяч копий одного меню — это
кэш, который не помогает никому. Персональные блоки (`PersonalBlockContent`,
в том числе цена и кнопка покупки) не кэшируются вовсе, какой бы TTL ни стоял.

Кэш счётчика товаров под фильтром — не микрооптимизация. Точный `COUNT`
по ста тысячам строк стоит около восьмидесяти миллисекунд, сколько его
ни оптимизируй: это чтение всего диапазона индекса. Комбинации галочек
повторяются, потому что их выбирают из одного и того же списка фильтров, —
второй посетитель с тем же фильтром считать заново не должен. Подробности —
[catalog.md](catalog.md#производительность).

Ключ payload блока включает идентификатор раздела: один и тот же блок на разных
страницах выводит разное — меню подсвечивает текущий раздел, крошки строят
свою цепочку.

## Memo в памяти запроса

`SectionTree`, `LayoutRepository`, `RedirectResolver`, `SettingsRepository`,
`CityRepository` и `CustomComponents`
держат разобранные данные в памяти, чтобы не десериализовать их по нескольку
раз за запрос.

Этот memo **привязан к номеру версии**, а не просто к сайту:

```php
$version = $this->cache->version($siteId, CacheVersion::Layout);

if (isset($this->loaded[$siteId][$version])) {
    return $this->loaded[$siteId][$version];
}
```

Без этого изменение, сделанное в том же запросе, не было бы видно: счётчик
инкрементирован, а объект продолжал бы отдавать старое значение из памяти.
Сценарий из жизни — админка сохранила блок и тут же рендерит превью.
Этот баг был реально допущен и закрыт тестами.

## Инвалидация и массовые операции

Наблюдатели на моделях ловят только поштучные `save`/`delete`. Массовые
операции вида `Model::query()->delete()` **событий Eloquent не поднимают** —
после них кэш остался бы протухшим.

Поэтому всё, что меняет данные пачкой, идёт через writer, который сам
инкрементирует счётчик:

| Операция | Класс |
|---|---|
| пути разделов, переименование, перенос, удаление | `SectionWriter` |
| редиректы | `RedirectWriter` |
| правила видимости блоков, порядок блоков | `LayoutWriter` |

## Защита от лавины

`rememberGuarded()` берёт блокировку на время пересборки. При одновременном
промахе триста запросов не будут собирать дерево разделов триста раз — соберёт
один, остальные дождутся результата. Применяется к дорогим пересборкам: дерево
разделов, раскладка сайта.

На драйверах без поддержки блокировок деградирует до обычного `remember`.

## Воркеры очередей

`SiteCache::flushRequestState()` сбрасывает memo счётчиков. Вызывается из
слушателя `JobProcessing` вместе с `forgetScopedInstances()` — иначе
долгоживущий воркер отдал бы данные одного арендатора при обработке задачи
другого.

## Что НЕ кэшируется

**Цены.** Они зависят от пользователя, его группы, города и количества в
корзине — кэшировать их означало бы либо отдать чужую цену, либо получить
комбинаторный взрыв ключей.

**Видимость блоков.** Фильтрация выполняется над закэшированной раскладкой на
каждый запрос: видимость зависит от устройства, города и номера страницы,
и кэширование по этим осям дало бы тот же взрыв.

**Полная страница.** Полностраничного HTML-кэша в первой версии нет
сознательно. Запасной ход, не требующий изменений в коде, — nginx microcache
на 1–5 секунд с обходом по сессионной куке.
