docs(plans): add accepted cache optimization spec

Implementation-ready specification for the cache overhaul, accepted after
4 rounds of adversarial review (1 blocker and 3 majors found and fixed).
Work is tracked in gitea issues #23 (packages A+B+F), #24 (C, depends
on #23), #25 (D), #26 (E).
This commit is contained in:
vvzvlad
2026-07-05 19:25:48 +03:00
parent 4fc8ebbd03
commit b42e892c3f
+619
View File
@@ -0,0 +1,619 @@
# Спецификация: рефакторинг и оптимизация кешей pyrogram-bridge
Статус: **принята владельцем 2026-07-05. Адверсарное ревью — 4 раунда, возражений не осталось.**
История ревью — раздел 12. Реализация разбита на ишью в gitea (по PR-юнитам: A+B+F, C, D, E).
## 0. Контекст и цели
В проекте три слоя кеша:
1. **История сообщений и инфо о канале** — pickle-файлы в `data/tgcache/` (`tg_cache.py`).
2. **Медиафайлы** — файлы в `data/cache/<channel>/<post_id>/<file_unique_id>` + метаданные в SQLite `data/media_file_ids.db` (`api_server.py`, `file_io.py`).
3. **Runtime-структуры**`_access_updates`, `_inflight`, `download_queue` в `api_server.py`.
Проблемы, которые закрывает спецификация (по результатам аудита):
| # | Проблема | Пакет |
|---|---|---|
| 1 | Pickle полных `Message` — формат привязан к версии pyrogram/kurigram, тихий дрейф схемы, opaque-формат | A |
| 2 | Строгое `cached_limit == limit` + разные limit у RSS (`limit*2`) и HTML (`limit`) → кеш истории фактически не работает | B |
| 3 | Один канал живёт под ключами разного регистра/формы записи (`Durov`/`durov`, `@name`/`name`) в трёх слоях → дубли кеша. Унификация ID↔username — сознательная НЕ-цель (см. раздел 8) | C |
| 4 | Два почти идентичных pickle-кеша в `tg_cache.py` (история/chatinfo) — дублирование кода | A |
| 5 | Legacy-мусор (`*_history.cache`, двойной pickle, `data/media_file_ids.json`); чистка `data/tgcache` вводится как стартовая + периодический age-sweep (мгновенной GC по событию не будет — файлы мёртвых каналов живут до 7 суток) | A, F |
| 6 | Свипер медиа-кеша: полный проход таблицы + `os.walk` каждые 60 с при политиках «20 дней»/«1 час» | D |
| 7 | Баг: чистка «мёртвых» строк SQLite выполняется только при `files_removed > 0` | D |
| 8 | Чтение MIME из SQLite (новое соединение + threadpool-hop) на каждом хите `/media` | E |
| 9 | Джиттер TTL перебрасывается на каждом чтении → недетерминированное поведение у границы TTL (усложняет рассуждения и тесты) | F |
| 10 | Очередь фоновых загрузок без дедупа — повторная постановка одного файла каждым проходом свипа | D |
| 11 | Путь медиа-кеша (`./data/cache` + join) собирается вручную в 7 местах | E |
## 1. Состав пакетов и порядок
| Пакет | Задания | Что | Зависимости | Файлы |
|---|---|---|---|---|
| **A** | 1–5 | Снапшот-словари вместо pickle + generic JSON-store + чистка legacy tgcache | — | `message_snapshot.py` (новый), `tg_cache.py`, `api_server.py` (1 строка), тесты |
| **B** | 6–7 | Префиксная выдача истории вместо строгого `limit` | после A | `tg_cache.py`, тесты |
| **C** | 8–11 | Единая канонизация ключа канала + one-shot миграция | **после A** (задание 9.1 канонизирует ключ внутри `_cache_file_path`, который создаётся заданием 2) | `channel_key.py` (новый), `tg_cache.py`, `post_parser.py`, `api_server.py` |
| **D** | 12–14 | Фиксы свипера медиа-кеша + дедуп очереди | независим | `api_server.py`, `config.py` |
| **E** | 15–16 | Хелпер путей медиа-кеша + in-memory MIME-кеш | независим | `api_server.py` |
| **F** | 17–18 | Джиттер TTL при записи + age-sweep tgcache + добить legacy | после A | `tg_cache.py`, `api_server.py` |
Рекомендуемая сборка: A → B → F одним PR (все правят `tg_cache.py`); C — отдельный PR (меняет генерацию URL и содержит миграцию); D и E — параллельно с любым.
---
## 2. Пакет A — кеш истории на извлечённых словарях
### 2.1. Принцип
Кеш истории перестаёт хранить pickle живых объектов pyrogram. При записи из `Message`
извлекается JSON-словарь по явному allowlist полей (снапшот); при чтении из него
восстанавливается лёгкий duck-typed объект `CachedMessage`, неотличимый для конвейера
рендеринга (`rss_generator.py`, `post_parser.py`) от настоящего `Message`.
**Конвейер не меняется вообще** — главный инвариант дизайна.
Выгоды: формат на диске отвязан от версии pyrogram/kurigram; версионирование схемы
(несовпадение → честный miss вместо тихого дрейфа); JSON читаем при отладке;
явный документированный контракт «от каких полей Message зависит рендер»
(сейчас он размазан по ~2000 строк); файл меньше и парсится быстрее полного графа объектов.
Отклонённая альтернатива — раздел 11.
### 2.2. Ключевые проектные решения
| # | Решение | Обоснование |
|---|---|---|
| Р1 | Новый модуль `message_snapshot.py`: `snapshot_message()` / `restore_message()` | Схема сериализации отделена от механики кеша; меняются независимо |
| Р2 | `text`/`caption` хранятся парой `{plain, html}`; `.html` вычисляется **при записи** через pyrogram `Str.html` | Конвертация entities→HTML требует машинерии pyrogram, доступной на живом объекте (`post_parser.py:674-675`). Проверено: `Str.html` работает офлайн, без клиента |
| Р3 | При восстановлении `text`/`caption``CachedStr(str)` с атрибутом `.html` | Потребители используют и строковые операции (`len`, `strip`, regex, `or`), и `.text.html`; str-подкласс покрывает всё. Это паттерн pyrogram `Str` |
| Р4 | `media` восстанавливается в настоящий `MessageMediaType[name]`; `KeyError``None` + warning | 31 место сравнивает с enum и использует его ключом dict (`post_parser.py:1004-1017`) |
| Р5 | `service` хранится и восстанавливается **строкой-именем** (`"PINNED_MESSAGE"`) | Все потребители — truthiness и `'X' in str(service)` (`rss_generator.py:112-121`, `post_parser.py:305`). Строка версионно-устойчива |
| Р6 | `date``isoformat()` туда, `fromisoformat()` обратно | Сохраняет naive/aware ровно как у pyrogram; `strftime`, `timestamp()`, сортировки работают |
| Р7 | Вложенные объекты восстанавливаются с **полным набором ключей схемы, None-дефолты** — «как живые». **Единственное исключение — `forward_origin`**: у него присутствие ключа зеркалит присутствие атрибута на исходном объекте | Живые pyrogram-объекты всегда имеют все атрибуты (ставятся в `__init__`, напр. `Reaction`: `emoji=None, custom_emoji_id=None, is_paid=None`) — код обращается к вложенным полям и напрямую (`rss_generator.py:131``message.chat.username` в except-хендлере; `post_parser.py:1087-1090``u.username`/`u.active` без getattr), omit-None дал бы там `AttributeError` → 500 на фид. А вот живые `MessageOrigin*`-классы имеют РАЗНЫЕ наборы атрибутов, и `_format_forward_info` ветвится по `hasattr` (Case 1–5, `post_parser.py:629-669`) — для forward_origin присутствие ключей семантично |
| Р8 | `CachedMessage` — мутабельный, полный набор top-level атрибутов с дефолтами `None`/`False` | `_reply_enrichment` присваивает `message.reply_to_message` (`rss_generator.py:574`); доступ к атрибутам не должен кидать `AttributeError`; `copy.deepcopy` (`rss_generator.py:43`) должен работать |
| Р9 | Файл: JSON `{"version", "timestamp", "limit", "messages"}`, имена `<key>.history.json` / `<key>.chatinfo.json`. Атомарная запись: **уникальный tmp `<path>.tmp.<uuid4().hex>`**`os.replace`, в `finally` — удаление своего tmp (образец: `_download_atomic`, `api_server.py:460-479`) | Версия отсекает старые схемы; новые расширения игнорируют старые pickle; уникальный tmp исключает перемешивание байтов при конкурентных miss'ах одного канала (RSS+HTML параллельно пишут через `asyncio.to_thread`); rename чинит порчу при падении посреди записи |
| Р10 | TTL / джиттер / строгий `limit` в пакете A — **без изменений** | Behavior-neutral рефакторинг; limit меняет пакет B, джиттер — пакет F |
| Р11 | Миграции старых pickle нет: старые файлы = miss; стартовая чистка удаляет legacy | Кеш самовосстанавливается за ≤ TTL (история 8 ч; chatinfo 12 ч по умолчанию, настраивается `TG_CHAT_CACHE_TTL_HOURS`) |
### 2.3. Схема снапшота v1 (контракт)
Выведена из фактического потребления полей конвейером (инвентарь по `rss_generator.py`
и `post_parser.py`, включая `hasattr`-семантику и enum-сравнения) и сверена с типами
kurigram 2.2.23 (`.venv`).
```jsonc
{
"id": 123, // message.id
"date": "2026-07-05T12:00:00", // isoformat or null
"text": {"plain": "...", "html": "..."}, // null if absent
"caption": {"plain": "...", "html": "..."}, // null if absent
"media": "PHOTO", // MessageMediaType.name or null
"service": "PINNED_MESSAGE", // MessageServiceType.name or null
"media_group_id": 456, // or null
"views": 789, // or null
"show_caption_above_media": false,
"reply_to_message_id": 42, // needed by _reply_enrichment
"empty": false,
"chat": {"id": -100123, "username": "durov", "title": "...",
"usernames": [{"username": "x", "active": true}]},
"sender_chat": {"id": 1, "title": "...", "username": "..."},
"from_user": {"first_name": "...", "last_name": "...", "username": "..."},
"forward_origin": { // ONLY keys present on the live object (hasattr semantics!)
"type": "channel",
"chat": {"id": 1, "title": "...", "username": "..."},
"sender_user_name": "...",
"sender_user": {"first_name": "...", "last_name": "...", "username": "..."},
"chat_id": 1, "title": "..."
},
"reactions": [ // from message.reactions.reactions; null if none
{"emoji": "👍", "count": 5, "is_paid": null, "custom_emoji_id": null},
// custom-emoji reaction: emoji is null, custom_emoji_id is a STRING (kurigram
// builds it as str(document_id)) — restored with the FULL key set, like live objects
// (live kurigram sets is_paid=None unless it is a paid reaction):
{"emoji": null, "count": 2, "is_paid": null, "custom_emoji_id": "987..."}
],
"poll": {"question": "...", // plain string — FormattedText unwrapped at snapshot time
"options": [{"text": "..."}]}, // text unwrapped to plain string; see 2.4.1
"web_page": {"type": "...", "url": "...", "display_url": "...", "site_name": "...",
"title": "...", "description": "...", "has_large_media": false,
"photo": {"file_unique_id": "..."}},
// media payloads — per-type allowlist:
"photo": {"file_unique_id": "..."},
"video": {"file_unique_id": "...", "file_size": 123}, // file_size: large-video rule
"document": {"file_unique_id": "...", "mime_type": "..."}, // mime_type: PDF branch
"audio": {"file_unique_id": "...", "mime_type": "..."},
"voice": {"file_unique_id": "...", "mime_type": "..."},
"video_note": {"file_unique_id": "..."},
"animation": {"file_unique_id": "..."},
"sticker": {"file_unique_id": "...", "emoji": "...", "is_video": false}
}
```
### 2.4. Критические тонкости (обязательны к учёту)
1. **`poll.question` и КАЖДЫЙ `poll.options[].text` в kurigram 2.2.23 — всегда объекты
`FormattedText`** (проверено: `poll.py:105,220`, `poll_option.py:72,84` в `.venv`), не строки.
Снапшот обязан разворачивать оба в plain-строку правилом
`v.text if hasattr(v, 'text') else str(v)` (правило корректно и для `Str`, и для голой
строки). Если положить `FormattedText` в словарь как есть — `json.dump` кинет `TypeError`,
`_save_history_to_cache` проглотит её, и **кеш молча перестанет сохраняться для любого
канала с опросом в выборке**. Восстановление: `question` → str,
`options` → namespace с `.text` (голая строка в options дала бы
`getattr(option, 'text', '') == ''` → пустые опции, `post_parser.py:992`).
2. **Реакции восстанавливаются с полным набором ключей** (`emoji`, `custom_emoji_id`,
`count`, `is_paid`; отсутствующие значения — `None`) — ровно как живой `Reaction`,
у которого `__init__` всегда ставит все атрибуты. Ветвление в
`post_parser.py:399-410` и `:928-934` на живых объектах работает через truthiness,
а не через отсутствие атрибута — восстановленный объект обязан вести себя так же.
`custom_emoji_id`**строка** (kurigram: `str(reaction.document_id)`).
3. **`forward_origin`** — единственный объект с presence-семантикой: Case 4 различается по
`hasattr(forward_origin, "chat_id") and hasattr(..., "title")`; при снапшоте пишутся
только реально присутствующие (non-None) поля-кандидаты, при восстановлении — только
записанные ключи.
4. **`video.file_size` обязателен** — правило «не кешировать видео >100 МБ» в
`_save_media_file_ids` (`post_parser.py:1058`).
5. **`chat.usernames`** — список объектов с `.username`/`.active`
(`post_parser.py:1087-1090`, прямой доступ без getattr — оба ключа всегда присутствуют).
6. **`CachedMessage.__str__`** — читаемый JSON-дамп: попадает в error-логи
(`post_parser.py:980`).
### 2.5. Задание 1 — новый модуль `message_snapshot.py`
```python
SNAPSHOT_VERSION = 1
class CachedStr(str):
"""str subclass carrying a precomputed .html rendering, mirroring pyrogram's Str.
deepcopy/pickle preserve the instance __dict__, so .html survives copying."""
# factory: CachedStr.build(plain, html)
class CachedMessage:
"""Duck-typed stand-in for pyrogram Message, restored from a snapshot dict.
Mutable (reply enrichment assigns .reply_to_message). All pipeline-consumed
top-level attributes exist with None/False defaults, so getattr never raises."""
# __str__/__repr__: json.dumps of the snapshot dict, default=str
def snapshot_message(message) -> dict: ...
def restore_message(data: dict) -> CachedMessage: ...
def snapshot_messages(messages) -> list[dict]: ...
def restore_messages(items: list[dict]) -> list[CachedMessage]: ...
```
Требования:
- `snapshot_message` извлекает строго по схеме 2.3; каждое поле через `getattr(..., None)`.
- Styled-text (`FormattedText`/`Str`/str) разворачивается правилом
`v.text if hasattr(v, 'text') else str(v)` — применяется к `poll.question` и каждому
`poll.options[].text` (см. 2.4.1).
- `text`/`caption`: `{"plain": str(value), "html": value.html}`; если `.html` недоступен —
`html = plain`.
- `media`: `message.media.name`; `service`: `message.service.name`
(оба через безопасный `getattr(x, 'name', str(x))`).
- Восстановление: помощник `_ns(d: dict, keys: tuple) -> SimpleNamespace` — ставит ВСЕ
перечисленные ключи схемы (None-дефолт для отсутствующих); для `forward_origin` — режим
«только записанные ключи». Спец-обработка: `text/caption → CachedStr`,
`media → MessageMediaType[name]` (при `KeyError` — warning + `None`),
`date → datetime.fromisoformat`, `reactions → SimpleNamespace(reactions=[...])`
(обёртка-контейнер, как у pyrogram).
- Дефолты `CachedMessage`: `id, date, text, caption, media, service, media_group_id, views,
show_caption_above_media, reply_to_message_id, reply_to_message=None, empty=False, chat,
sender_chat, from_user, forward_origin, reactions, poll, web_page, photo, video, document,
audio, voice, video_note, animation, sticker`.
- Комментарии в коде — только английские.
### 2.6. Задание 2 — переписать `tg_cache.py` на generic JSON-store
1. Убрать `import pickle` полностью. Удалить ветку double-pickle (`tg_cache.py:111-116`).
2. Generic-пара (заменяет обе дублирующиеся тройки функций):
```python
def _store_entry(path: str, payload: dict) -> None:
"""Atomically write {'version', 'timestamp', **payload} as JSON.
Writes to a unique '<path>.tmp.<uuid4hex>' and os.replace()s it into place;
the finally block always removes this writer's own tmp file."""
def _load_entry(path: str, max_age_hours: float) -> Optional[dict]:
"""Return payload dict, or None on: missing file, version mismatch,
expired TTL (keep the existing up-to-20% read-time jitter as-is in package A;
package F moves it to write time), JSON error."""
```
3. Пути: `<safe_key>.history.json` и `<safe_key>.chatinfo.json`
(общий помощник `_cache_file_path(key, suffix)` вместо двух копий).
4. `_save_history_to_cache`: `payload = {'limit': limit, 'messages': snapshot_messages(messages)}`.
`_get_history_from_cache`: проверка `limit` как сейчас (пакет A не меняет), возврат
`restore_messages(...)`.
5. `_save_chat_to_cache` / `_get_chat_from_cache`: тот же store, `payload = {'data': {...}}`.
6. Новая функция `cleanup_legacy_cache_files() -> int` — удаляет из `CACHE_DIR` файлы
`*.cache` и `*.chatinfo` (старые pickle-форматы, включая `*_history.cache`), возвращает
число удалённых. Ошибки удаления — warning, не исключение.
7. Сигнатуры и поведение `cached_get_chat_history` / `cached_get_chat` не меняются:
на miss возвращаются живые `Message`, на hit — `CachedMessage`; обе формы duck-совместимы
(как сегодня pickle-копия vs живой объект).
### 2.7. Задание 3 — стартовая чистка в `api_server.py`
В `lifespan` после `init_db_sync`, **до** запуска фоновых задач:
```python
# One-shot cleanup of legacy pickle cache files (pre-JSON formats)
await asyncio.to_thread(cleanup_legacy_cache_files)
```
Больше в `api_server.py` в рамках пакета A ничего не трогать.
### 2.8. Задание 4 — тесты `tests/test_message_snapshot.py`
Фейковые `Message` — на `SimpleNamespace` (паттерн существующих тестов); `text` — мини-класс
`str` с атрибутом `.html`. Обязательные кейсы:
1. Round-trip основных полей: id, date (naive и aware — оба сохраняют tz-ность), text.html,
caption.html, media enum (`is MessageMediaType.PHOTO`), views, media_group_id.
2. **Poll с FormattedText-подобными объектами**: фейк `question = SimpleNamespace(text="Q?",
entities=[])`, каждый option — `SimpleNamespace(text=SimpleNamespace(text="Opt",
entities=[]))`. Проверить: снапшот JSON-сериализуем (`json.dumps` не падает);
восстановленный `poll.question` — строка; `getattr(option, 'text', '') == "Opt"`.
Тест обязан ПАДАТЬ, если снапшот кладёт FormattedText как есть.
3. Реакции: обычная / paid / custom-emoji. У восстановленной custom-реакции:
`hasattr(r, 'emoji') is True`, `r.emoji is None`, `r.custom_emoji_id` — строка;
ветка в `_reactions_views_links`-подобной логике выбирается по truthiness, как у живой.
4. forward_origin Case 1–5: `hasattr`-ветвление выбирает правильный case
(проверять именно наличие/отсутствие атрибутов — здесь presence-семантика сохраняется).
5. service: `'PINNED_MESSAGE' in str(restored.service)`.
6. Мутабельность + deepcopy: присвоение `msg.reply_to_message = X`;
`copy.deepcopy(restored)` сохраняет `.text.html`.
7. Неизвестный media name (`"FUTURE_TYPE"`) → `media is None`, без исключения.
8. Store: `_store_entry`/`_load_entry` — round-trip, TTL-протухание, version mismatch → None,
битый JSON → None; уникальность tmp-путей: перехватить `os.replace` (или замокать
`uuid.uuid4`) и проверить, что два вызова `_store_entry` в один path использовали
РАЗНЫЕ tmp-имена, а итоговый файл валиден — одиночный «конкурентный» прогон окно
гонки не ловит и сломанную реализацию с фиксированным tmp пропустит;
`cleanup_legacy_cache_files` удаляет `*.cache`/`*.chatinfo`, не трогает
`*.history.json` (использовать `tmp_path`).
9. `_save_media_file_ids` на восстановленном сообщении с `video.file_size` > 100 МБ ничего
не добавляет в `_pending_media_ids`.
10. Восстановленный `chat` для канала без username: `message.chat.username` — `None`
(не `AttributeError`) — регресс-тест на правило Р7 (сценарий `rss_generator.py:131`).
### 2.9. Задание 5 — верификация пакета A
- `pytest` — весь существующий набор зелёный.
- `grep pickle tg_cache.py` — пусто; `post_parser.py` и `rss_generator.py` не изменены.
---
## 3. Пакет B — история: префикс вместо строгого равенства `limit`
**Проблема:** `tg_cache.py:106-109` требует `cached_limit == limit`; RSS просит `limit*2`
(`rss_generator.py:419`), HTML — `limit` (`rss_generator.py:639`), оба пишут в один файл.
Чередование запросов превращает кеш в решето.
### 3.1. Задание 6 — логика префикса в `_get_history_from_cache`
Сообщения в кеше лежат от новых к старым (порядок `get_chat_history`), поэтому запрос
с меньшим `limit` — это префикс:
```python
cached_limit = payload.get('limit', 0)
raw_messages = payload['messages']
# Serve from cache when it was fetched with an equal-or-larger limit, OR when the
# channel is exhausted (fewer messages exist than were asked for at fetch time —
# the cache then holds the channel's entire recent history and satisfies ANY limit).
if cached_limit < limit and len(raw_messages) >= cached_limit:
log("history_cache_limit_insufficient: cached %s < requested %s", cached_limit, limit)
return None
return restore_messages(raw_messages[:limit])
```
- `_save_history_to_cache` не меняется: хранит `limit`, с которым делался fetch.
- Лог хита дополнить: `served {limit} of cached {cached_limit}`.
- Сходимость: первый запрос с большим `limit` после протухания перезапишет кеш «широкой»
версией, дальше меньшие запросы — хиты.
### 3.2. Задание 7 — тесты
1. Кеш `limit=100` (100 сообщений) → запрос `limit=50` — хит, ровно 50 первых, порядок сохранён.
2. Кеш `limit=50` → запрос `limit=100` — промах.
3. Исчерпанный канал: кеш `limit=100`, 37 сообщений → запрос `limit=200` — хит, 37 сообщений.
4. TTL работает независимо от limit.
---
## 4. Пакет C — единая канонизация ключа канала
**Проблема:** один канал живёт под ключами `Durov` / `durov` / `@name` в tgcache-файлах,
каталогах `data/cache/` и строках SQLite → дубли кеша, двойные походы в Telegram,
осиротевшие деревья при смене регистра/формы. (Унификация ID↔username — не-цель, раздел 8.)
### 4.1. Задание 8 — новый модуль `channel_key.py`
Отдельный модуль без зависимостей (его импортируют `tg_cache`, `post_parser`, `api_server` —
отдельность исключает циклические импорты):
```python
def canonical_channel_key(channel: str | int) -> str:
"""Canonical cache/DB key for a channel.
Telegram usernames are case-insensitive -> lowercase them.
Numeric '-100...' ids keep their exact string form.
The '@' prefix is stripped.
The canonical form is also SAFE to pass to Telegram API calls (usernames are
case-insensitive on the API side; the numeric form is unchanged) — callers that
thread one value through both filesystem paths and API calls may use it for both.
"""
s = str(channel).strip().lstrip('@')
if s.startswith('-100') and s[4:].isdigit():
return s
return s.lower()
```
### 4.2. Задание 9 — применить ключ во всех трёх слоях
1. **`tg_cache.py`**: в `_cache_file_path(key, suffix)` прогонять ключ через
`canonical_channel_key`.
2. **`post_parser.get_channel_username`** (`post_parser.py:1081-1097`): возвращать username
в нижнем регистре (обе ветки — `usernames`-список и одиночный `username`). Это
канонизирует записи SQLite (`_save_media_file_ids`), генерируемые media-URL и `t.me`-ссылки
(t.me к регистру нечувствителен).
3. **`api_server.get_media`** (`api_server.py:1153-1183`): после проверки digest вычислить
`fs_channel = canonical_channel_key(channel)` и использовать вместо `str(channel)` везде
ниже: pre-semaphore путь, ключ `_access_updates`, `media_key`, аргумент `_download_deduped`
(протекает в `download_media_file` → каталоги, API-вызов и удаление из SQLite — для API
канонический идентификатор безопасен, см. docstring 4.1).
**Digest проверять по исходной строке URL** — иначе сломаются все выданные ссылки.
### 4.3. Задание 10 — one-shot миграция существующих данных
Без миграции каждый ранее закешированный медиафайл канала с не-lowercase username даёт
после деплоя cache-miss → повторную загрузку из Telegram (шторм ограничен такими каналами
и фактически запрашиваемыми файлами, но страховка дешёвая — делаем).
Новая функция `migrate_channel_keys_sync(db_path: str, cache_dir: str) -> None`. Вызов —
в `lifespan` **после `init_db_sync`, ДО `client.start()` и до запуска фоновых задач**,
строго через `await asyncio.to_thread(migrate_channel_keys_sync, ...)` — функция
синхронная (FS-rename + SQLite), голый вызов заблокировал бы event loop на время
миграции; размещение до `client.start()` дополнительно исключает влияние на сетевые
таски pyrogram и гонки со свипером/access-flush.
Единица работы — **канал целиком, сначала FS, потом SQL** (порядок принципиален:
при провале FS-части SQL-строки канала остаются старорегистровыми, и старое дерево
по-прежнему видно свиперу через строки БД — вечных orphan-каталогов не возникает):
1. Найти каналы-кандидаты: имена каталогов первого уровня в `cache_dir` с не-lowercase
именем (кроме `-100...`) ∪ `SELECT DISTINCT channel ... WHERE channel != lower(channel)
AND channel NOT LIKE '-100%'`.
2. Для каждого канала — **FS-шаг**:
- **ОБЯЗАТЕЛЬНЫЙ guard до любых действий**: если `os.path.exists(dst)` и
`os.path.samefile(src, dst)` — **no-op FS-шага, перейти к SQL-шагу**. На
case-insensitive FS (macOS/APFS, Docker Desktop for Mac — том `./data` наследует
семантику хоста) `Durov/` и `durov/` — один и тот же каталог; merge без guard'а
удалил бы весь кеш канала.
- `dst` не существует → `os.rename(src, dst)`.
- `dst` существует и это другой каталог (case-sensitive FS, обе формы реально есть) →
пофайловый merge: существующий файл в `dst` выигрывает; затем удалить пустые
остатки `src`.
- FS-шаг упал (EACCES и т.п.) → log error с пометкой orphan-кандидата, **SQL-шаг
канала пропустить**, перейти к следующему каналу.
3. **SQL-шаг** (только после успешного FS-шага): для каждой строки канала:
- lowercase-строки с тем же `(post_id, file_unique_id)` нет — просто `UPDATE` канала;
- есть — merge: `added = max(added)` из двух, `mime_type` — предпочесть non-NULL;
затем `DELETE` старорегистровой строки. Голый `UPDATE ... SET channel=lower(channel)`
запрещён — ловит PK-конфликт при наличии обеих форм.
4. Ошибки миграции — log error + продолжить работу (миграция не должна уронить старт);
повторный запуск функции — идемпотентный no-op.
5. **Итоговая сводка одной строкой лога** (сигнал оператору, что деплой C прошёл штатно):
`migration_summary: rows merged N, dirs renamed M, samefile no-ops K, failures F`.
### 4.4. Задание 11 — тесты + заметка о миграции
- Юнит: `'Durov'→'durov'`, `'@durov'→'durov'`, `-1001…` (int и str) → `'-1001…'`.
- Интеграция: путь tgcache-файла одинаков для `Durov`/`durov`; `get_channel_username`
на фейковом chat с `username='MixedCase'` → `'mixedcase'`.
- Миграция: (а) SQL-merge обеих форм — остаётся одна строка с `max(added)` и non-NULL
`mime_type`; (б) guard: вызов с `src`/`dst`, резолвящимися в один каталог (samefile), —
no-op, данные целы; (в) merge на двух реально разных каталогах — файлы объединены,
target выигрывает; (г) повторный запуск — no-op.
- В PR: (а) остаточные старорегистровые строки/каталоги, не покрытые миграцией (например,
появившиеся между бэкапом и деплоем), доживают до 20-дневного вытеснения сами;
(б) **миграция односторонняя** — reverse-миграции нет; откат пакета C после её
выполнения возвращает код со старорегистровыми ключами на lowercase-данные и повторяет
re-download-сценарий зеркально (ограниченный, самоизлечивается за 20 дней) — это
ожидаемый признак отката, не авария.
---
## 5. Пакет D — свипер медиа-кеша
### 5.1. Задание 12 — фикс чистки БД (баг)
`api_server.py:820-836`: diff и удаление из SQLite выполняются только `if files_removed > 0`.
Запись старше 20 дней **без файла на диске** выпадает из списка без инкремента счётчика
(`api_server.py:663-687`) — и остаётся в БД навсегда, если в том же проходе не удалился
ни один реальный файл. Исправление:
```python
# Compute the DB diff unconditionally: entries dropped from the list because the
# file was already gone must still be purged from SQLite.
updated_set = {...}
removed_entries = [...]
if removed_entries:
await asyncio.to_thread(remove_media_file_ids_sync, DB_PATH, removed_entries)
logger.info(f"cache_sweep: purged {len(removed_entries)} entries "
f"({files_removed} files removed from disk)")
```
### 5.2. Задание 13 — интервал свипа в конфиг
Сейчас `delay = 60` (`api_server.py:811`). Добавить в `config.py` по существующему паттерну
ключ `cache_sweep_interval` (env `CACHE_SWEEP_INTERVAL`, дефолт `900`, минимум `60`),
использовать в `cache_media_files`.
### 5.3. Задание 14 — дедуп очереди фоновых загрузок
Module-level `_queued_media: set[tuple[str, int, str]]`:
- в `download_new_files`: ключ в set'е → skip; иначе `add` + `put_nowait`
(при `QueueFull` — `discard` и `break` как сейчас);
- в `background_download_worker`: в `finally` рядом с `task_done()` — `discard(key)`.
Event loop однопоточный, обе точки — корутины без await между проверкой и мутацией → гонок нет.
Тест: два подряд вызова `download_new_files` с одним списком кладут в очередь один элемент.
---
## 6. Пакет E — горячий путь `/media`
### 6.1. Задание 15 — константа и хелпер путей
`os.path.abspath("./data/cache")` + ручной `join` собираются в **семи** местах
(`api_server.py:207, 532-536, 667, 757-766, 817, 851, 1172-1175`). Ввести на уровне модуля:
```python
MEDIA_CACHE_DIR = os.path.abspath(os.path.join("data", "cache")) # resolved once at import
def media_cache_path(channel: str, post_id: int, file_unique_id: str | None = None) -> str:
"""Single source of truth for the on-disk layout: <root>/<channel>/<post_id>[/<fid>]."""
```
Заменить все семь мест. `remove_old_cached_files_sync`/`download_new_files` сохраняют
параметр `cache_dir` (тестируемость), вызыватели передают константу.
### 6.2. Задание 16 — in-memory кеш MIME
Запись access-time уже вынесена с горячего пути в аккумулятор, а чтение MIME до сих пор
делает threadpool-hop + новое SQLite-соединение на каждый хит (`api_server.py:389-404`).
MIME по ключу `file_unique_id` иммутабелен.
```python
# MIME types are immutable per file_unique_id, so a process-lifetime dict in front of
# SQLite removes a to_thread + connect from every cache-hit response.
_mime_types: dict[tuple[str, int, str], str] = {}
_MIME_CACHE_MAX = 50_000 # crude bound; clear-all on overflow is fine at this size
```
Порядок в `prepare_file_response`: dict → (miss) SQLite → (miss) python-magic → записать
и в SQLite, и в dict. При успешном чтении из SQLite — заполнить dict. При переполнении —
`clear()`. Тест: замокать `get_mime_type_sync`, два запроса подряд — второй его не вызывает.
---
## 7. Пакет F — довесок к новому store
### 7.1. Задание 17 — джиттер TTL при записи
Сейчас `random_factor` перебрасывается на каждом чтении (`tg_cache.py:98, 192`) — срок жизни
записи недетерминирован, что усложняет рассуждения о поведении и тесты (одна и та же запись
у границы TTL может дать разный результат в соседних чтениях; устойчивого «мигания» нет —
первый же miss ведёт к перезаписи со свежим timestamp). В generic-store пакета A:
- `_store_entry` дополнительно пишет `'jitter': random.uniform(0.8, 1.0)`;
- `_load_entry` считает `adjusted_max_age = max_age_hours * 3600 * entry.get('jitter', 1.0)`
и не дёргает `random` при чтении.
Срок жизни записи детерминирован после записи, разброс между инстансами/каналами сохраняется.
Тест: одна и та же запись около границы TTL даёт стабильный результат при повторных чтениях.
### 7.2. Задание 18 — age-sweep tgcache + добить legacy-данные
1. Новая функция `sweep_tgcache(max_age_days: int = 7) -> int` в `tg_cache.py`: удаляет из
`CACHE_DIR` любые файлы с mtime старше порога. Живые файлы перезаписываются каждые ≤12 ч,
поэтому mtime > 7 суток — гарантированно мёртвый файл (умерший/переименованный канал,
неканоничный ключ, осиротевший uuid-tmp от упавшего писателя). Гонка с писателем
ВОЗМОЖНА (stat→unlink не атомарен против `os.replace`: sweep может удалить файл,
обновлённый между stat и unlink), но безвредна — худший исход один лишний miss-refetch;
гонка с читателем так же самоизлечивается как miss. Не выдавать это за инвариант
атомарности в комментариях кода.
2. Вызовы: (а) один раз при старте — рядом с `cleanup_legacy_cache_files` (задание 3);
(б) из цикла `cache_media_files` раз в проход, **обязательно через `asyncio.to_thread`**
(listdir+stat+unlink — синхронный FS I/O; инвариант кодовой базы — ничего блокирующего
на event loop). Каталог плоский, стоимость — миллисекунды.
3. Стартовая legacy-чистка (задание 3) остаётся: sweep покрывает legacy-файлы сам, но
с задержкой до 7 суток; чистка по расширениям делает это мгновенно.
4. Расширить стартовую чистку: удалить `data/media_file_ids.json` (наследие до-SQLite
хранилища, кодом не читается — проверено grep). Лог — одной строкой с перечнем удалённого.
---
## 8. Границы (сознательно НЕ входит)
- **Унификация ID↔username** (`/rss/-100…` и `/rss/durov` одного канала остаются разными
ключами во всех слоях): потребовала бы резолва через `get_chat` (лишний RPC, свои отказы
и кеш-инвалидация). Канонизация пакета C устраняет только дубли регистра/формы записи.
- Переезд метаданных медиа с SQLite куда-либо ещё — SQLite остаётся.
- Кеширование результатов рендеринга (processed dicts / готовый HTML фида) — отдельная
большая тема с инвалидацией по параметрам запроса.
- Оптимизация `calculate_cache_stats` (полный walk на `/health`) — низкий приоритет.
- `SimpleNamespace` → dataclass в `cached_get_chat` — косметика.
## 9. Сквозные критерии приёмки
1. `pytest` зелёный; `post_parser.py`/`rss_generator.py` изменены только в объёме
задания 9.2 (lowercase в `get_channel_username`).
2. `grep pickle tg_cache.py` — пусто.
3. Сценарий «RSS(limit=100) → HTML(limit=50) → RSS(100)» порождает **один** поход в Telegram
(сейчас — три).
4. `/rss/Durov` и `/rss/durov` используют один tgcache-файл; `/media/Durov/...` и
`/media/durov/...` — один файл на диске.
5. Повторный `/media`-хит не создаёт SQLite-соединений (ни на чтение MIME, ни на запись
access-time). Рецепт проверки: monkeypatch `file_io._open_db` счётчиком соединений;
два последовательных запроса TestClient к одному файлу — на втором счётчик не растёт.
6. Строка SQLite с `added` старше 20 дней и отсутствующим файлом исчезает из БД за один
проход свипа.
7. Кеш истории сохраняется и для каналов с опросами в выборке (снапшот JSON-сериализуем).
8. Миграция ключей на case-insensitive FS — no-op без потери данных (samefile-guard).
## 10. Риски
| Риск | Закрытие / статус |
|---|---|
| Пропущенное поле allowlist → тихая деградация рендера | **Живой риск, не закрыт полностью.** Инвентарь снят по всем потребителям (2.3–2.4) и сверен с kurigram 2.2.23; ревью нашло и закрыло два пропуска (FormattedText в poll, omit-None семантика) — но гарантии полноты ручного инвентаря нет. Митигция: тесты 2.8, деградация проявляется как выпадение конкретного элемента рендера, не крэш; TTL 8 ч ограничивает окно |
| Смешение живых `Message` (miss) и `CachedMessage` (hit) | Так работает и текущий pickle-кеш; живой объект — надмножество контракта |
| Порча файла при падении посреди записи / конкурентная запись | Уникальный tmp + `os.replace` + finally-очистка (Р9) |
| Старые pickle после деплоя | Игнорируются новыми именами + `cleanup_legacy_cache_files()` |
| Канонизация: существующие данные со старым регистром | One-shot миграция (задание 10) с samefile-guard; остатки доживают до 20-дневного LRU |
| Миграция на case-insensitive FS | Обязательный `os.path.samefile`-guard → no-op (критерий 9.8) |
## 11. Рассмотренные альтернативы
**Version-stamped pickle** (минимальный вариант): оставить pickle, дописать в конверт
`pyrogram.__version__` + версию схемы, несовпадение считать miss'ом (~5 строк). Закрывает
«тихий дрейф» и стоимость апгрейда библиотеки (один TTL-эквивалентный miss на канал —
пренебрежимо при TTL 8 ч). **Отклонена решением владельца проекта** (альтернатива
предлагалась явно на этапе аудита как «рекомендация-минимум»; выбран стратегический
вариант) по причинам: (а) исходная цель — «проще и поддерживаемее»: JSON читаем глазами
при отладке, pickle — нет; (б) снапшот делает зависимость рендера от полей `Message`
явным документированным контрактом (схема 2.3) вместо размазанной по ~2000 строк;
(в) меньший файл и дешёвый parse вместо unpickle графа объектов в threadpool.
Цена: ручной инвентарь полей с остаточным риском пропуска (раздел 10, первый пункт).
## 12. История ревью
Спецификация прошла два раунда адверсарного ревью (субагент-критик с доступом к кодовой
базе и установленному kurigram 2.2.23). Итоги:
- **C1 (BLOCKER, принято)**: исходная схема не разворачивала `FormattedText` в
`poll.options[].text` → `json.dump` падал бы, кеш молча переставал сохраняться для
каналов с опросами; исходные тест-фикстуры дефект не ловили. Исправлено: 2.4.1, тест 2.8.2.
- **C2 (MAJOR, принято с усилением)**: blanket omit-None ломал эквивалентность с живыми
объектами (у которых все атрибуты всегда присутствуют) и давал крэш-пути
(`rss_generator.py:131`, `post_parser.py:1087`). Итог: правило Р7 «полные ключи, кроме
forward_origin».
- **C3 (MAJOR, принято)**: канонизация без миграции → повторные загрузки медиа после деплоя.
Добавлено задание 10. Встречное возражение критика к первой версии миграции
(**потеря кеша на case-insensitive FS**) принято: обязательный samefile-guard.
- **C4 (MAJOR → MINOR)**: критик требовал обосновать отказ от version-stamped pickle.
Закрыто разделом 11; довод «холодный кеш на каждый бамп библиотеки» в обоснование
сознательно НЕ включён (стоимость — один miss на канал, пренебрежимо). Остаточный MINOR:
риск ручного инвентаря остаётся живым (раздел 10).
- **C5–C12 (MINOR, приняты)**: уникальный tmp; docstring `canonical_channel_key`;
сужение проблемы #3; седьмое место сборки пути (`:817`); TTL 8–12 ч; `custom_emoji_id: str`;
честная мотивация джиттера; age-sweep tgcache (+`to_thread` из фонового цикла).
- **Раунд 4 (новые векторы: атака на собственные правки критика, межпакетные
взаимодействия, реализуемость, рантайм-семантика, операционка): 6 MINOR, все приняты.**
D1 — `to_thread` + размещение миграции до `client.start()`; D2 — порядок «FS → SQL
per-channel», чтобы частичный отказ FS не рождал orphan-деревья, невидимые свиперу;
D3 — гонка sweep/писатель честно названа возможной-но-безвредной; D4 — зависимость
«C после A» зафиксирована в таблице (задание 9.1 опирается на `_cache_file_path` из
задания 2); D5 — тест 2.8.8 переписан на дискриминирующий (уникальность tmp-путей),
критерий 9.5 получил рецепт проверки; D6 — итоговая сводка миграции в лог + пометка
об односторонности миграции при откате. По направлениям «откаты A/B/F», «смешение
живых и восстановленных объектов», «json.dumps(CachedStr)», «deepcopy», «exclude_*»
критик существенных находок не нашёл (с доказательствами по коду: hit/miss атомарен
на весь список, `/json`-эндпоинт идёт мимо кеша истории, tz-гомогенность гарантирована Р6).