Кеши, PR-юнит 1 (пакеты A+B+F): снапшот-кеш истории вместо pickle, префиксный limit, джиттер при записи + age-sweep #23
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
ТЗ: переработка кеша истории (
tg_cache.py)Часть принятой спеки
plans/cache-optimization-spec.md(адверсарное ревью — 4 раунда, возражений нет). Ишью самодостаточно: всё нормативное содержимое — ниже. Один PR на всё ишью. Комментарии в коде — только английские. Никаких правок вне описанного объёма.Цель
Кеш истории (
data/tgcache/) перестаёт хранить pickle живых объектов pyrogram. При записи изMessageизвлекается JSON-словарь по явному allowlist (снапшот); при чтении восстанавливается duck-typedCachedMessage, неотличимый для конвейера рендеринга отMessage.post_parser.pyиrss_generator.pyне меняются вообще — главный инвариант.Задание 1 — новый модуль
message_snapshot.pyСхема снапшота v1 (контракт; сверена с kurigram 2.2.23)
Правила снапшота/восстановления
getattr(..., None).Poll.questionи КАЖДЫЙPollOption.text— всегда объектыFormattedText. Разворачивать правиломv.text if hasattr(v, 'text') else str(v)(корректно и дляStr, и для голой строки). Если положитьFormattedTextкак есть —json.dumpкинетTypeError, save проглотит её, и кеш молча перестанет сохраняться для каналов с опросами.text/caption:{"plain": str(value), "html": value.html};.htmlвычисляется при записи (у живого pyrogramStr); если.htmlнедоступен —html = plain. Восстановление —CachedStr.media:message.media.name, восстановлениеMessageMediaType[name], приKeyError— warning +None(31 место в post_parser сравнивает с enum и использует его ключом dict).service: имя-строка; все потребители — truthiness и'X' in str(service).date:isoformat()/fromisoformat()— сохраняет naive/aware ровно как у pyrogram.__init__; код обращается к ним и напрямую:rss_generator.py~131message.chat.usernameв except-хендлере,post_parser.py~1087u.username/u.activeбез getattr). Единственное исключение —forward_origin: присутствие ключа зеркалит присутствие атрибута на исходном объекте (живыеMessageOrigin*-классы имеют РАЗНЫЕ наборы атрибутов,_format_forward_infoветвится поhasattr, Case 1–5).emoji/custom_emoji_id/count/is_paid(None-дефолты); восстановление в обёрткуSimpleNamespace(reactions=[...]), как у pyrogram.poll.options— восстанавливать namespace-объектами с.text(голая строка дала быgetattr(option, 'text', '') == ''→ пустые опции).video.file_sizeобязателен (правило «не кешировать видео >100 МБ» в_save_media_file_ids)._ns(d: dict, keys: tuple) -> SimpleNamespace— все ключи схемы с None-дефолтом; для forward_origin — режим «только записанные ключи».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. Объект мутабельный (_reply_enrichmentприсваиваетreply_to_message),deepcopy-совместимый.Задание 2 — переписать
tg_cache.pyна generic JSON-storeimport pickleполностью; удалить legacy-ветку double-pickle.<safe_key>.history.json/<safe_key>.chatinfo.json, общий помощник_cache_file_path(key, suffix)._save_history_to_cache:payload = {'limit': limit, 'messages': snapshot_messages(messages)}._save_chat_to_cache/_get_chat_from_cache— тот же store,payload = {'data': {...}}.cleanup_legacy_cache_files() -> int: удаляет изCACHE_DIR*.cacheи*.chatinfo(включая*_history.cache); ошибки — warning.cached_get_chat_history/cached_get_chatне меняются: miss → живыеMessage, hit →CachedMessage.Задание 6 (пакет B) — префикс вместо строгого равенства
limitВ
_get_history_from_cache(сообщения лежат от новых к старым):_save_history_to_cacheхранит limit, с которым делался fetch (неlen(messages)). Лог хита:served {limit} of cached {cached_limit}.Задание 17 (пакет F) — джиттер TTL при записи
_store_entryпишет'jitter': random.uniform(0.8, 1.0);_load_entryсчитаетadjusted_max_age = max_age_hours * 3600 * entry.get('jitter', 1.0)и не дёргаетrandomпри чтении.Задание 18 (пакет F) — age-sweep tgcache + legacy-данные
sweep_tgcache(max_age_days: int = 7) -> intвtg_cache.py: удаляет изCACHE_DIRфайлы с mtime старше порога (мёртвые каналы, осиротевшие uuid-tmp). Гонка с писателем ВОЗМОЖНА (stat→unlink не атомарен против os.replace), но безвредна — худший исход один лишний miss; НЕ выдавать за инвариант атомарности в комментариях.cache_media_filesраз в проход, обязательно черезasyncio.to_thread.lifespanпослеinit_db_sync, до фоновых задач:await asyncio.to_thread(cleanup_legacy_cache_files)+ sweep; удалить такжеdata/media_file_ids.json(наследие до-SQLite, кодом не читается). Больше вapi_server.pyничего не трогать.Задания 4–5, 7 — тесты (
tests/test_message_snapshot.py) и верификацияis MessageMediaType.PHOTO), views, media_group_id.question = SimpleNamespace(text="Q?", entities=[]), option —SimpleNamespace(text=SimpleNamespace(text="Opt", entities=[])). Проверить:json.dumpsснапшота не падает; восстановленныйpoll.question— строка;getattr(option, 'text', '') == "Opt". Тест ОБЯЗАН падать, если снапшот кладёт FormattedText как есть.hasattr(r, 'emoji') is True,r.emoji is None,r.custom_emoji_id— строка.'PINNED_MESSAGE' in str(restored.service)..text.htmlпереживает deepcopy)."FUTURE_TYPE") →media is None, без исключения.os.replace(или замокатьuuid.uuid4) и проверить, что два вызова_store_entryв один path использовали РАЗНЫЕ tmp-имена (одиночный «конкурентный» прогон гонку не ловит);cleanup_legacy_cache_filesне трогает*.history.json;sweep_tgcacheудаляет только старые._save_media_file_idsна восстановленном сообщении с video.file_size > 100 МБ ничего не добавляет в_pending_media_ids.message.chat.username is None(не AttributeError).Критерии приёмки
pytestцеликом зелёный;post_parser.pyиrss_generator.pyНЕ изменены.grep pickle tg_cache.py— пусто.Примечания
Полный сет работ по кешам (спека
plans/cache-optimization-spec.md, принята 2026-07-05):Рекомендуемый порядок: #23 → #24; #25 и #26 — в любой момент параллельно.
⚠️ Пересечение с эпиком рефакторинга рендера (#34, спека
plans/2026-07-05-render-pipeline-refactor.md)Проверено по коду
f9550d8(веткаfeat/kurigram-2.2.23, PR #21) — есть конфликт корректности, который надо снять ДО реализации.1. Аллоулист снапшота (задание 1) НЕ покрывает новые медиа-типы kurigram 2.2.23, которые рендер читает. Схема v1 обрывается на
sticker. Аpost_parser._format_special_mediaи_generate_html_media(f9550d8) читают top-level атрибуты:live_photo,story,paid_media,giveaway,giveaway_winners,checklist,contact,location,venue,dice,game, а такжеpoll.description_media. В дефолтахCachedMessageих нет → на cache-hitgetattr(message, 'giveaway', None)вернётNone:description_mediaпотеряет картинку И перевернёт флагno_image.На cache-miss (живой
Message) всё рендерится верно → дивергенция hit/miss, невидимая для golden рендера (#27 реплеит настоящий pickleMessage, а прод после этого PR кормитCachedMessage).Действие: расширить аллоулист снапшота до ПОЛНОГО набора полей, которые читает рендер. Единый источник правды для этого набора — таблица
MEDIA_SOURCES+_format_special_media-ветки из этапа 5 рендера (#32). Оба эпика описывают один контракт «какие поля Message трогает рендер» — они обязаны совпасть, иначе cache-hit-рендер расходится с live.2. Тест round-trip (задания 4–5) должен доказывать рендер-эквивалентность, а не только полей. Добавить: прогнать восстановленный
CachedMessageчерезpost_parser._generate_html_media(и_format_special_media) и сравнить БАЙТ-В-БАЙТ с рендером исходногоMessage— по матрице типов. Можно переиспользовать fragment-снапшоты этапа 5a (#32). Это ловит именно класс «CachedMessage не несёт атрибут, который читает рендер».3. Golden рендера (#27) — это pickle настоящих
Message. После этого PR прод-путь cache-hit отдаётCachedMessage. Записанный корпус (tests/test_data/recorded/) остаётся валидным входом (реплеится через monkeypatchcached_get_chat_history), но проверять он будетMessage, а неCachedMessage— поэтому пункт 2 обязателен.Сиквенсинг
Рекомендация: этот PR (#23) authored ПОСЛЕ фиксации таблицы
MEDIA_SOURCES(#32, этап 5a) — тогда аллоулист снапшота выводится из неё, а не переоткрывается вручную. Если #23 идёт раньше — аллоулист всё равно обязан покрыть полный список полей выше, и это надо зафиксировать явно.Связано: #34, #27, #32.