Files
pyrogram-bridge/plans/cache-optimization-spec.md
T
vvzvlad b42e892c3f 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).
2026-07-05 19:25:48 +03:00

52 KiB

Спецификация: рефакторинг и оптимизация кешей 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/captionCachedStr(str) с атрибутом .html Потребители используют и строковые операции (len, strip, regex, or), и .text.html; str-подкласс покрывает всё. Это паттерн pyrogram Str
Р4 media восстанавливается в настоящий MessageMediaType[name]; KeyErrorNone + 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 dateisoformat() туда, 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:131message.chat.username в except-хендлере; post_parser.py:1087-1090u.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).

{
  "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

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-пара (заменяет обе дублирующиеся тройки функций):
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."""
  1. Пути: <safe_key>.history.json и <safe_key>.chatinfo.json (общий помощник _cache_file_path(key, suffix) вместо двух копий).
  2. _save_history_to_cache: payload = {'limit': limit, 'messages': snapshot_messages(messages)}. _get_history_from_cache: проверка limit как сейчас (пакет A не меняет), возврат restore_messages(...).
  3. _save_chat_to_cache / _get_chat_from_cache: тот же store, payload = {'data': {...}}.
  4. Новая функция cleanup_legacy_cache_files() -> int — удаляет из CACHE_DIR файлы *.cache и *.chatinfo (старые pickle-форматы, включая *_history.cache), возвращает число удалённых. Ошибки удаления — warning, не исключение.
  5. Сигнатуры и поведение cached_get_chat_history / cached_get_chat не меняются: на miss возвращаются живые Message, на hit — CachedMessage; обе формы duck-совместимы (как сегодня pickle-копия vs живой объект).

2.7. Задание 3 — стартовая чистка в api_server.py

В lifespan после init_db_sync, до запуска фоновых задач:

# 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.usernameNone (не 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 — это префикс:

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 — отдельность исключает циклические импорты):

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) — и остаётся в БД навсегда, если в том же проходе не удалился ни один реальный файл. Исправление:

# 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 (при QueueFulldiscard и 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). Ввести на уровне модуля:

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 иммутабелен.

# 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[].textjson.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).