From b42e892c3fb2baf90e82f6185557ccd51e7a02f8 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Sun, 5 Jul 2026 19:25:48 +0300 Subject: [PATCH] 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). --- plans/cache-optimization-spec.md | 619 +++++++++++++++++++++++++++++++ 1 file changed, 619 insertions(+) create mode 100644 plans/cache-optimization-spec.md diff --git a/plans/cache-optimization-spec.md b/plans/cache-optimization-spec.md new file mode 100644 index 0000000..4678302 --- /dev/null +++ b/plans/cache-optimization-spec.md @@ -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///` + метаданные в 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"}`, имена `.history.json` / `.chatinfo.json`. Атомарная запись: **уникальный tmp `.tmp.`** → `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 '.tmp.' 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. Пути: `.history.json` и `.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: //[/].""" +``` + +Заменить все семь мест. `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).