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).
52 KiB
Спецификация: рефакторинг и оптимизация кешей pyrogram-bridge
Статус: принята владельцем 2026-07-05. Адверсарное ревью — 4 раунда, возражений не осталось. История ревью — раздел 12. Реализация разбита на ишью в gitea (по PR-юнитам: A+B+F, C, D, E).
0. Контекст и цели
В проекте три слоя кеша:
- История сообщений и инфо о канале — pickle-файлы в
data/tgcache/(tg_cache.py). - Медиафайлы — файлы в
data/cache/<channel>/<post_id>/<file_unique_id>+ метаданные в SQLitedata/media_file_ids.db(api_server.py,file_io.py). - 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).
{
"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. Критические тонкости (обязательны к учёту)
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).- Реакции восстанавливаются с полным набором ключей (
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)). forward_origin— единственный объект с presence-семантикой: Case 4 различается поhasattr(forward_origin, "chat_id") and hasattr(..., "title"); при снапшоте пишутся только реально присутствующие (non-None) поля-кандидаты, при восстановлении — только записанные ключи.video.file_sizeобязателен — правило «не кешировать видео >100 МБ» в_save_media_file_ids(post_parser.py:1058).chat.usernames— список объектов с.username/.active(post_parser.py:1087-1090, прямой доступ без getattr — оба ключа всегда присутствуют).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
- Убрать
import pickleполностью. Удалить ветку double-pickle (tg_cache.py:111-116). - 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."""
- Пути:
<safe_key>.history.jsonи<safe_key>.chatinfo.json(общий помощник_cache_file_path(key, suffix)вместо двух копий). _save_history_to_cache:payload = {'limit': limit, 'messages': snapshot_messages(messages)}._get_history_from_cache: проверкаlimitкак сейчас (пакет A не меняет), возвратrestore_messages(...)._save_chat_to_cache/_get_chat_from_cache: тот же store,payload = {'data': {...}}.- Новая функция
cleanup_legacy_cache_files() -> int— удаляет изCACHE_DIRфайлы*.cacheи*.chatinfo(старые pickle-форматы, включая*_history.cache), возвращает число удалённых. Ошибки удаления — warning, не исключение. - Сигнатуры и поведение
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. Обязательные кейсы:
- Round-trip основных полей: id, date (naive и aware — оба сохраняют tz-ность), text.html,
caption.html, media enum (
is MessageMediaType.PHOTO), views, media_group_id. - Poll с FormattedText-подобными объектами: фейк
question = SimpleNamespace(text="Q?", entities=[]), каждый option —SimpleNamespace(text=SimpleNamespace(text="Opt", entities=[])). Проверить: снапшот JSON-сериализуем (json.dumpsне падает); восстановленныйpoll.question— строка;getattr(option, 'text', '') == "Opt". Тест обязан ПАДАТЬ, если снапшот кладёт FormattedText как есть. - Реакции: обычная / paid / custom-emoji. У восстановленной custom-реакции:
hasattr(r, 'emoji') is True,r.emoji is None,r.custom_emoji_id— строка; ветка в_reactions_views_links-подобной логике выбирается по truthiness, как у живой. - forward_origin Case 1–5:
hasattr-ветвление выбирает правильный case (проверять именно наличие/отсутствие атрибутов — здесь presence-семантика сохраняется). - service:
'PINNED_MESSAGE' in str(restored.service). - Мутабельность + deepcopy: присвоение
msg.reply_to_message = X;copy.deepcopy(restored)сохраняет.text.html. - Неизвестный media name (
"FUTURE_TYPE") →media is None, без исключения. - 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). _save_media_file_idsна восстановленном сообщении сvideo.file_size> 100 МБ ничего не добавляет в_pending_media_ids.- Восстановленный
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 — это префикс:
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 — тесты
- Кеш
limit=100(100 сообщений) → запросlimit=50— хит, ровно 50 первых, порядок сохранён. - Кеш
limit=50→ запросlimit=100— промах. - Исчерпанный канал: кеш
limit=100, 37 сообщений → запросlimit=200— хит, 37 сообщений. - 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 — применить ключ во всех трёх слоях
tg_cache.py: в_cache_file_path(key, suffix)прогонять ключ черезcanonical_channel_key.post_parser.get_channel_username(post_parser.py:1081-1097): возвращать username в нижнем регистре (обе ветки —usernames-список и одиночныйusername). Это канонизирует записи SQLite (_save_media_file_ids), генерируемые media-URL иt.me-ссылки (t.me к регистру нечувствителен).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-каталогов не возникает):
- Найти каналы-кандидаты: имена каталогов первого уровня в
cache_dirс не-lowercase именем (кроме-100...) ∪SELECT DISTINCT channel ... WHERE channel != lower(channel) AND channel NOT LIKE '-100%'. - Для каждого канала — 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-шаг канала пропустить, перейти к следующему каналу.
- ОБЯЗАТЕЛЬНЫЙ guard до любых действий: если
- SQL-шаг (только после успешного FS-шага): для каждой строки канала:
- lowercase-строки с тем же
(post_id, file_unique_id)нет — простоUPDATEканала; - есть — merge:
added = max(added)из двух,mime_type— предпочесть non-NULL; затемDELETEстарорегистровой строки. ГолыйUPDATE ... SET channel=lower(channel)запрещён — ловит PK-конфликт при наличии обеих форм.
- lowercase-строки с тем же
- Ошибки миграции — log error + продолжить работу (миграция не должна уронить старт); повторный запуск функции — идемпотентный no-op.
- Итоговая сводка одной строкой лога (сигнал оператору, что деплой 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-NULLmime_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(при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). Ввести на уровне модуля:
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-данные
- Новая функция
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. Не выдавать это за инвариант атомарности в комментариях кода. - Вызовы: (а) один раз при старте — рядом с
cleanup_legacy_cache_files(задание 3); (б) из циклаcache_media_filesраз в проход, обязательно черезasyncio.to_thread(listdir+stat+unlink — синхронный FS I/O; инвариант кодовой базы — ничего блокирующего на event loop). Каталог плоский, стоимость — миллисекунды. - Стартовая legacy-чистка (задание 3) остаётся: sweep покрывает legacy-файлы сам, но с задержкой до 7 суток; чистка по расширениям делает это мгновенно.
- Расширить стартовую чистку: удалить
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. Сквозные критерии приёмки
pytestзелёный;post_parser.py/rss_generator.pyизменены только в объёме задания 9.2 (lowercase вget_channel_username).grep pickle tg_cache.py— пусто.- Сценарий «RSS(limit=100) → HTML(limit=50) → RSS(100)» порождает один поход в Telegram (сейчас — три).
/rss/Durovи/rss/durovиспользуют один tgcache-файл;/media/Durov/...и/media/durov/...— один файл на диске.- Повторный
/media-хит не создаёт SQLite-соединений (ни на чтение MIME, ни на запись access-time). Рецепт проверки: monkeypatchfile_io._open_dbсчётчиком соединений; два последовательных запроса TestClient к одному файлу — на втором счётчик не растёт. - Строка SQLite с
addedстарше 20 дней и отсутствующим файлом исчезает из БД за один проход свипа. - Кеш истории сохраняется и для каналов с опросами в выборке (снапшот JSON-сериализуем).
- Миграция ключей на 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).