# Поведение OPFS‑кеша: лимиты, LRU и оповещения

Документ описывает, как плагины пакета управляют кешем в OPFS: ограничение размера, вытеснение по давности использования (LRU), обработка нехватки места и оповещение вкладок. Цель — понять, что происходит при кешировании и почему некоторые ресурсы могут не попадать в кеш или удаляться из него.

---

## 1. Зачем нужны лимиты

Браузер выделяет origin **квоту** хранилища (`navigator.storage.estimate().quota`). В неё входят OPFS, IndexedDB, Cache API и др. Квота не даёт выйти за лимит (при переполнении возникает `QuotaExceeded`), но **не гарантирует**, что «хотя бы один большой файл влезет» — на слабых устройствах квота может быть очень маленькой.

Лимиты в плагинах нужны не для «защиты диска», а чтобы:

1. **Делить квоту** — OPFS‑кеш не должен занять всю квоту; место остаётся для остальных хранилищ приложения.
2. **Включить LRU** — без верхней границы кеш рос бы до упора и никогда не чистился; лимит задаёт порог, при достижении которого начинается вытеснение старых файлов.

---

## 2. Конфигурация

**Зачем папки:** разным группам файлов нужны разные логические кеши (паттерны include, **folderName** в метаданных файлов и т.д.). **Папка** — имя такой группы.

У каждого плагина в опциях обязателен **`folderName: string`** — одна папка = одна регистрация в реестре. Если несколько плагинов используют одну папку, они указывают один и тот же **folderName**.

**Глобальный лимит:** **getGlobalMaxCacheFraction()** (по умолчанию `0.5`) и **setGlobalMaxCacheFraction(fraction)** задают долю квоты origin, которую может занимать кеш OPFS плагина. **getMaxCacheFraction()** (без аргументов) возвращает эту глобальную долю. Фактический лимит байтов считается в **getCacheLimit(estimate)**.

Фактический лимит кеша в байтах вычисляется так:

```
лимит = min(quota × getMaxCacheFraction(), quota − usage)
```

То есть кеш не может быть больше ни доли квоты, ни текущего свободного места. `quota` и `usage` берутся из `navigator.storage.estimate()`.

Для удобства в конфиге можно использовать экспортируемые константы:

- **`KILOBYTE`**, **`MEGABYTE`**, **`GIGABYTE`** — размеры в байтах (1024, 1024², 1024³).

---

## 3. Как хранятся данные и индекс эвикции

- Все папки кеша плагина лежат в **корневой папке плагина** **`OPFS_PLUGIN_ROOT_DIR_NAME`** (по умолчанию **`.opfs-serve-range`**) в корне OPFS. Итоговый путь: корень OPFS → `.opfs-serve-range` → `folderName` (из опций). Так данные плагина не смешиваются с папками других приложений; имя с точкой в начале подчёркивает, что это служебная папка и вручную её трогать не стоит.
- Один файл в OPFS на один URL (ключ — хеш URL).
- В конце каждого файла — футер с метаданными (JSON + 4 байта длины). В метаданных есть **`lastAccessed`** (timestamp) и **`evictable`** (false = закреплён; не вытесняется по LRU).
- **Индекс эвикции** — файл **`_eviction_index.json`** в той же папке кеша. В нём только **evictable**-записи: `{ key, size, lastAccessed }`. Он используется для LRU: выбор файлов для вытеснения и порядок. Если индекс отсутствует или повреждён, он **пересобирается** по сканированию папки и чтению футеров файлов. Индекс также **перезаписывается**, если при сканировании каталога найдено больше evictable-записей, чем в текущем индексе (например, после перезагрузки при пустом индексе и уже закешированных файлах). Все операции с индексом сериализованы in-memory lock’ом.

При **чтении** (ответ по Range из кеша) **`lastAccessed`** обновляется **только в индексе** (в фоне через `event.waitUntil`), не чаще раза в 5 с на файл при частых запросах (перемотка), не в футере файла — так избегается одновременное чтение/запись одного файла OPFS (ошибки при перемотке). При **записи** нового файла текущее время пишется в футер и при evictable — в индекс добавляется запись.

---

## 4. Запись при известном размере (есть Content-Length)

Когда размер ответа известен по заголовку `Content-Length`:

1. Вычисляется, сколько байт нужно **освободить**, чтобы новый файл влез в лимит и в квоту:  
   `needToFree = max(0, текущий_размер_кеша + размер_файла − лимит, …)` (с учётом квоты при необходимости).
2. **Проверка «влезет ли вообще»:**  
   Если для этого пришлось бы удалить больше, чем есть в кеше (т.е. даже удалив все файлы, места не хватит), запись **не начинается**, загрузка в кеш не выполняется, клиенту отправляется оповещение (например, о превышении квоты / отказе в кешировании).
3. Если места достаточно после эвикции: по списку файлов кеша (размер + `lastAccessed`) выбирается **минимальный набор** файлов для удаления — те, к которым дольше всего не обращались, пока сумма их размеров не станет не меньше `needToFree`. Удаляются **только эти** файлы.
4. После удаления выполняется запись нового файла.

Таким образом, при известном размере мы не «удаляем подряд, пока не влезет», а один раз вычисляем, что удалить, удаляем только нужное и затем пишем.

---

## 5. Запись при неизвестном размере (поток без Content-Length)

Когда размер неизвестен (ответ приходит потоком):

- Запись в OPFS идёт потоком. Если в процессе возникает **QuotaExceeded** (закончилась квота), запись прерывается.

После ошибки:

1. **Частичный файл всегда удаляется** — в кеше не остаётся битой записи.
2. Считается, сколько байт успели записать до ошибки (**bytesWritten**), и сколько места занимает весь текущий кеш (**totalCacheSize**).
3. **Решение, удалять ли что-то ещё:**
   - Если **bytesWritten ≥ totalCacheSize** — мы уже записали не меньше, чем можно освободить удалением всего кеша. Значит, этот ресурс «больше, чем мы вообще можем записать» при текущей квоте. **Ничего не удаляем** (эвикция бессмысленна). URL заносится в **список ресурсов, которые не пытаемся кешировать повторно** (см. ниже).
   - Если **bytesWritten < totalCacheSize** — освободив часть кеша, мы могли бы иметь больше места. Тогда выполняется эвикция по LRU (освобождается объём порядка **bytesWritten + запас**), чтобы не держать кеш переполненным и дать шанс следующим загрузкам.

Поток повторно не читается — повторной попытки записи **того же** ответа нет; при следующем запросе к тому же URL придёт новый ответ и новый поток.

---

## 6. Список ресурсов, которые не кешируем повторно

Для запросов **без Content-Length** после QuotaExceeded мы можем решить: «даже удалив весь кеш, этот файл не влезет» (bytesWritten ≥ totalCacheSize). Тогда:

- **URL попадает в список** (в памяти сервис-воркера на время его жизни). При повторной попытке кешировать этот URL (новый запрос) плагин **не начинает** загрузку в кеш — не тратит трафик и время.
- При каждом таком **повторном запросе** клиенту отправляется **оповещение о превышении квоты** (например, что ресурс не кешируется из‑за лимитов). Так приложение может показывать пользователю сообщение вроде «недостаточно места для кеширования этого ресурса».

---

## 7. Оповещение вкладок

Сервис-воркер отправляет сообщения всем окнам-клиентам через утилиту пакета `@budarin/pluggable-serviceworker` (`notifyClients`). Типы сообщений и данные (например, `url`, размеры, лимиты) зависят от события:

- Квота исчерпана при записи (QuotaExceeded).
- Отказ в записи при известном размере (файл не влез бы даже после полной очистки кеша).
- Достигнут лимит кеша / выполнена эвикция.
- Ошибка записи (в т.ч. после удаления частичного файла).
- Повторный запрос к URL из «списка плохих» — отправляется сообщение о превышении квот, чтобы клиент мог показать предупреждение.

На клиенте можно подписаться на эти события через **типизированные обработчики** из этого пакета (например, `onOPFSQuotaExceeded`, `onOPFSWriteSkipped`, `onOPFSCacheLimitReached`, `onOPFSWriteFailed` и т.д.). Полный список клиентских утилит (включая хелперы работы с кешем вроде `listOpfsCachedResources`, `hasInOpfsCache`, `deleteFromOpfsCache`) с примерами использования приведён в разделе **«Клиентские утилиты»** README и в типах пакета.

---

## 8. Инвалидация при файловых ошибках (три уровня)

Обработка идёт по уровням:

1. **Файл** — при ошибке доступа к конкретному файлу (`getFileHandle(key)`, `getFile()`, чтение футера) выполняется **точечная инвалидация по opfsKey**: **invalidateCachesForFileKeyOnError** → **removeFromEvictionIndex** (metadata cache, range cache, eviction index по ключу).
2. **Папка** — если эта операция бросает (например папку `folderName` удалили), выполняется **полная инвалидация папки** (**invalidateAllCachesForFolder(folderName)**).
3. **Корневая папка плагина** — при ошибке доступа к папке кеша под плагин-корнем (`getOpfsDir` не может получить `folderName` из `.opfs-serve-range`) вызывается **invalidateAllCachesAndPluginRoot()**: сбрасывается кеш корня плагина и инвалидируются кэши **всех** зарегистрированных папок; затем выполняется одна повторная попытка получить папку (создание корня при необходимости). Так обрабатывается случай, когда удалили всю `.opfs-serve-range`.

В результате кэши не отдают устаревшие данные после удаления файла, папки или корневой папки плагина вне плагина.

---

## 9. Крайние случаи

- **Квота меньше одного файла** — при записи получим QuotaExceeded; частичный файл удалится, при необходимости URL попадёт в список «не кешируем», клиент получит оповещение.
- **Частичная запись при потоке** — всегда удаляется; решение об эвикции и skip list принимается по правилам выше.
- **Обновление `lastAccessed`** при чтении не блокирует ответ — выполняется в фоне (пишется только в индекс эвикции, не в файл).

---

## 10. Кеши в памяти

Плагины используют несколько in-memory кешей, чтобы снизить I/O и повторную работу. Все живут в рамках жизни сервис-воркера и сбрасываются или инвалидируются при очистке папки кеша или при эвикции записей.

- **Metadata cache**: глобальный LRU по **opfsKey**. Значение: `{ fullSize, type, etag?, lastModified?, evictable?, folderName? }`. Нужен, чтобы не читать футер файла при каждом запросе того же файла; первый запрос заполняет кеш, последующие используют его. По умолчанию 500 записей. Инвалидация при эвикции файлов (**removeFromEvictionIndex**) и при **clearOpfsCache**. Внутренний (не экспортируется).

- **Dir cache** (opfsUtil): ключ **folderName**, значение **FileSystemDirectoryHandle** из `root.getDirectoryHandle(folderName)`. **getOpfsDir(root, create, folderName)** возвращает закешированный handle при наличии; иначе вызывает API, кеширует и возвращает. Сбрасывается в **clearOpfsCache(folderName)**, чтобы не использовать handle после удаления папки.

- **Eviction index in-memory** (opfsEvictionIndex): на папку (по dir.name). Map ключ → `{ size, lastAccessed, evictable }`. Заполняется при первом обращении (например **getEntriesForEviction**, **updateEvictionIndexLastAccessed**); избегает повторных сканирований каталога. Файл на диске **\_eviction_index.json** пишется при обновлении индекса; запись **троттлится** не чаще раза в 5 секунд, с отложенным flush (setTimeout), чтобы при частых обновлениях (например перемотка) выполнялась одна запись. **lastAccessedUpdateByKey** (троттлинг по ключу) очищается при удалении ключей из индекса, чтобы Map не рос без ограничений.

- **Прочее:** **urlToKeyCache** (URL → opfsKey), **globRegexCache** (паттерн → RegExp), **skip list** (URL, которые не кешируем повторно), **loadingUrls** (URL фоновой полной загрузки), **folderRegistry** (зарегистрированные **folderName**). **getRoot()** кеширует корень OPFS.

---

Итог: кеш ограничен долей квоты и текущим свободным местом, вытеснение идёт по давности использования (LRU) с предварительным расчётом, какие файлы удалить; при потоке без размера после QuotaExceeded мы либо эвиктируем с запасом, либо заносим URL в список и при повторных запросах не кешируем и оповещаем клиента о превышении квот.

