feat(cache): снапшот-кеш истории вместо pickle + префиксный limit + джиттер + age-sweep #44
Reference in New Issue
Block a user
Delete Branch "feat/23-snapshot-cache"
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?
Summary
Кеш истории (
data/tgcache/) перестаёт хранить pickle живых объектов pyrogram. Новый модульmessage_snapshot.py: при записи изMessageизвлекается JSON-словарь по явному allowlist (снапшот), при чтении восстанавливается duck-typedCachedMessage(CachedStrс.html, mutable, deepcopy-safe), неотличимый для конвейера рендеринга отMessage.post_parser.pyиrss_generator.pyне изменены — главный инвариант соблюдён.tg_cache.pyпереписан на generic JSON-store (pickle удалён полностью):_store_entry/_load_entry(атомарная запись.tmp.<uuid4>+os.replace, TTL, версия, битый JSON→None); пути<key>.history.json/.chatinfo.json; пакет B — префиксный limit (RSS(100)→HTML(50)→RSS(100) = один поход в Telegram вместо трёх); пакет F — джиттер TTL при записи +sweep_tgcache+cleanup_legacy_cache_files;api_server.py— старт-cleanup+sweep вlifespan, per-pass sweep вcache_media_files, удаление legacydata/media_file_ids.json.closes #23
Форк для решения (внутреннее ревью — WARNING, не баг)
Allowlist снапшота (как в ТЗ) покрывает photo/video/document/audio/voice/video_note/animation/sticker + web_page/poll. Медиатипы вне allowlist —
story,live_photo,paid_media,giveaway,giveaway_winners,checklist,contact,location,venue,dice,game— на cache-hit теряют свой спец-блок/медиа (в_format_special_media/_story_media_objectгейт наgetattr(message, '<sub>', None)→ None → блок не рендерится). Крэша нет (инвариант «не падать на cache-hit» соблюдён), но при TTL истории 8ч большинство чтений — hit, так что для каналов с такими постами деградация будет почти всегда.Реализовано строго по ТЗ-allowlist (в задаче «никаких правок вне описанного объёма»), поэтому эти payload'ы я НЕ добавлял, чтобы не расширять scope. @vvzvlad — решай: (A) принять деградацию как компромисс (эти типы редки в лентах), или (B) отдельным follow-up добавить недостающие payload'ы в снапшот (contact/venue/location/giveaway/checklist — простые namespace-поля; story/live_photo — через
file_unique_idкак photo/video). Могу сделать B follow- up'ом.How verified
.venv/bin/python -m pytest tests/ -q→ 488 passed (из них 36 новых вtest_message_snapshot.py+test_tg_cache_store.py).grep -n pickle tg_cache.py— пусто;git diff --stat—post_parser.py/rss_generator.pyотсутствуют.is MessageMediaType.PHOTO), poll FormattedText unwrap, forward_origin presence-семантика (Case 1–5), custom-emoji реакции, naive/aware date, CachedStr.html через deepcopy, атомарность/уникальность tmp, префикс-логика без off-by-one, джиттер (random не зовётся на чтении).Внутренний цикл: 1 проход coder → внутреннее ревью (10 критических проверок дак-типинга) → 1 находка-деградация (см. форк выше), кода-багов нет.
Checklist
Ревью — #44 · feat(cache): снапшот-кеш истории вместо pickle (#23)
Вердикт: ПРАВКИ (CHANGES). Ядро сделано отлично (CachedStr.html + reduce, атомарный JSON-store, версия/TTL/джиттер/свип, pickle удалён полностью — security-win; симметрия snapshot/restore, forward_origin presence-semantics — всё верно, гейт 488 pass). НО allowlist снапшота НЕПОЛОН: класс спец-медиа теряется при кешировании → расхождение рендера (нарушение golden-render инварианта).
Сделать — потом повторное ревью
message_snapshot.py(snapshot_message+CachedMessage.__init__)_format_special_media(post_parser.py ~316-390) читаетgetattr(message, '<attr>', None)для story, contact, location, venue, dice, game, giveaway, giveaway_winners, checklist, paid_media. Проверено декисивно: все они — членыMessageMediaType, все читаются пайплайном, и ни одного нет в снапшоте (grep in_snapshot=0). Итог: закешированное сообщение такого типа восстанавливается сmedia= (имя переживает), ноmessage.<attr>=None (атрибут отсутствует на CachedMessage) → инфо-блок спец-медиа молча выпадает из HTML (у живого Message он есть). Плюсfind_file_id_in_message(api_server.py:396-403) для story не найдёт file-id. Fix: заснапшотить+восстановить каждый из этих атрибутов (поля — по тому, что читает_format_special_mediaиfind_file_id_in_message; story→.video/.photo.file_unique_id, game→.title, contact/location/venue/dice/giveaway/checklist/paid_media→нужные поля их инфо-блоков) + завести их наCachedMessage+ round-trip тест на каждый тип.tests/test_message_snapshot.py/tests/test_stage0_golden.pyСуществующий golden-тест всё ещё читает pickle-данные, а не новый JSON-снапшот; теста «отрендерить живой Message и восстановленный CachedMessage и сравнить байт-в-байт HTML+RSS» НЕТ. Мутационно доказано: удаление
web_page/sender_chat/from_user/show_caption_above_mediaиз allowlist — все тесты проходят (пропуск поля не ловится). Это корневая причина, по которой спец-медиа-пропуски прошли. Fix: добавить render-parity тест (snapshot→restore→render == live→render) по корпусу, покрывающему все media/спец-медиа/forward/poll кейсы; перегенерировать golden-фикстуры под JSON-снапшот (не pickle).tests/test_message_snapshot.py:1,tests/test_tg_cache_store.py:1Все 18+ существующих тест-файлов начинаются с
# flake8: noqa+ pylint-disable ДО докстринга; эти два — нет (может уронить lint в CI). Fix: добавить хедер-блок как в остальных.Как проверено (гейт, /home/claude/pb-venv, kurigram 2.2.23)
pytest tests/— 488 passed (exit 0). Инвариант структурно держится:post_parser.py/rss_generator.pyне в диффе..start/.end/.get— ложные срабатывания (regex-match/dict),.story+ getattr-строки-литералы (getattr(message,'contact')и т.д.) — реальные пропуски (см. DO1)._safe_keyрубит traversal (/,\→_), corrupt-JSON→miss.⛔ DROP — кодеру НЕ делать (лог калибровки)
[speculative]medium [security] hard-link в sweep/cleanup (os.remove без st_nlink) —tg_cache.py:282,314— НЕ эксплойт: os.remove на хардлинке удаляет ТОЛЬКО эту запись каталога, данные цели живут пока есть её собственный линк (/etc/* не удаляется); требует пред-существующего write в CACHE_DIR;_safe_keyрубит traversal. Пикл-удаление — реальный security-win. Опц. харднинг.[below-threshold]high [documentation] неверные line-refs в_nsдокстринге («rss_generator ~131»→~158, «post_parser ~1087»→~1417) + устаревший «31 sites»→65+ —message_snapshot.py:236,318— обоснование верное, номера строк — хрупкий nav-указатель, на корректность не влияет.[below-threshold]medium [test-coverage] web_page/sender_chat/from_user/show_caption_above_media покрыты только на существование — свёрнуто в DO2 (golden render-parity ловит весь класс).9 аспектов. Escalate снят: фикс allowlist'а — механический (источник истины —
_format_special_media), не форк. Прим.: coherence+architecture написали «field coverage complete», но грепали только.attr-дереференсы и ПРОПУСТИЛИ getattr-строки-литералы — проверка против кода (реальные reads_format_special_media) перевешивает.Поправил все три запрошенных пункта (allowlist спец-медиа, render-parity тест, lint-хедеры).
[stability/regressions] Allowlist пропускал спец-медиа → рендер расходился — исправлено. Заснапшочены+восстановлены все перечисленные типы (story, contact, location, venue, dice, game, giveaway, giveaway_winners, checklist, paid_media) — ровно те подполя, что читает
_format_special_media/find_file_id_in_message(сверено построчно).SNAPSHOT_VERSION1→2, так что закешированные v1-файлы без новых ключей инвалидируются (иначе старый баг пережил бы фикс через stale-кеш). Плюс докрыл live_photo — тот же класс бага (проверил, чтоMEDIA_SOURCES[LIVE_PHOTO]реально читает.live_photo, не покрытphoto/video).[test-coverage] Нет golden render-parity теста — добавлен
tests/test_snapshot_render_parity.py: корпус 35+ кейсов (plain/caption, все медиа-типы, все 11 спец-медиа, web_page, poll, forward Case 1-5, reactions normal/paid/custom, sender_chat/from_user, media group), для каждого рендерю ЖИВОЙ объект через реальныйrss_generator.generate_channel_html/rssиrestore(snapshot(live)), assert байт-в-байт HTML+RSS. Мутационно доказано, что зубастый (выбросgame.title/giveaway.description/любого спец-поля → тест краснеет). Существующий pickle-golden не переписывал — динамический live-vs-restored паритет самодостаточен и строже.[conventions] Lint-хедеры — добавлен
# flake8: noqa+ pylint-disable блок на все 3 новых тест-файла.Доп. находка внутреннего ревью (F1), тоже поправил:
file_sizeснапшотился только уvideo/live_photo, поэтому правило «>100MB не кэшировать» в_save_media_file_idsрасходилось на cache-hit для document/audio/animation/video_note/story-media (>100МБ документ ошибочно кэшировался). Досниманfile_sizeдля этих типов; тест на skip/collect + мутация (200МБ документ: без фикса collected, с фиксом skipped).Проверка:
pytest tests/ -q→ 547 passed;grep pickle tg_cache.pyпусто;post_parser.py/rss_generator.pyне в диффе.Внутренний цикл: coder(спец-медиа) → внутреннее ревью (F1: file_size) → фикс live_photo → фикс F1 → верификация. Готово к повторному ревью.
Ревью (раунд 2) — #44
feat(cache): снапшот-кеш истории вместо pickle + префиксный limit + джиттер + age-sweepВердикт: PASS ✅ — все три правки раунда 1 закрыты.
Полный веер из 9 аспектов по всему диффу PR (merge-base
3c9ce72b51..432715200f, 1812 ins / 100 del). Гейт зелёный:pytest tests/ -p no:cacheprovider→ 547 passed; новые тесты 95 passed (snapshot 40 + parity 37 + tg_cache_store 18).Закрытие правок раунда 1 (проверено адверсариально)
tests/test_snapshot_render_parity.pyстроит корпус живых fake-Message (все медиа + все спец-медиа: story/contact/location/venue/dice/game/giveaway/giveaway_winners/checklist/paid_media/live_photo, web_page, poll, forwards 1-5, reactions, авторы, media-group), рендерит ЖИВЫЕ через реальныйgenerate_channel_html/rss, затемsnapshot→restoreи рендерит снова, ассертит байт-идентичность HTML+RSS. Мутационно подтвердил, что оракул настоящий: выкинулvenueиз снапшота → падаетparity[venue]; выкинул ВЛОЖЕННОЕgiveaway.description→ падаетparity[giveaway]. Ловит и топ-левел, и вложенные потерянные поля. Плюс guardtest_corpus_covers_all_special_media_types. Round-1 DO (allowlist ронял спец-медиа) закрыт и защищён.test_snapshot_render_parity.py,test_message_snapshot.py) теперь несут flake8/pylint/pylance-блок.Проверено сверх правок (tg_cache rework + миграция + безопасность) — клик
test_no_pickle_import), устранён RCE-векторpickle.loadsна недоверенном кеш-файле. История/чатинфо переехали.cache/.chatinfo(pickle) →.history.json/.chatinfo.json.SNAPSHOT_VERSION=2,_load_entryversion-гейтит — старый v1-файл трактуется как промах кеша (ре-фетч), НЕ крэш и НЕ тихое расхождение (test_load_version_mismatch). Безопасная стратегия.os.replace, читатель не видит полу-файл), все I/O-ошибки → graceful degrade к промаху; jitter считается ОДИН раз на записи (read детерминирован, нет thundering herd —test_load_does_not_call_random); prefix-limit корректно различает «канал исчерпан» vs «лимит» (off-by-one>=→>ловится тестом); age-sweep (>7 дней + orphan.tmp.*).find_file_id_in_messageчитает snapshottedfile_unique_id.snapshot_message→ все восстанавливаются вCachedMessage, лишних нет. Архитектура/упрощение/conventions — LGTM.cleanup_legacy_cache_files()+sweep_tgcache()(в to_thread), без регрессий на hit/miss-путях.⛔ DROP — кодеру НЕ делать (лог калибровки)
[below-threshold]low/high[documentation] докстринг модуляmessage_snapshot.py:13говорит «(schema v1)», хотя кодSNAPSHOT_VERSION=2— устаревшая скобка, ПРОТИВОРЕЧИТ авторитетной константе + v2-changelog'у 15 строк ниже (строки 28-34, где всё верно). Не поведенческий дефект, читатель тут же видит верную версию из константы. Если будешь трогать файл — поправьv1→v2на стр. 13; отдельного раунда не стоит.[speculative]low/low[test-coverage] мутация порога age-sweep на 1 секунду (86400→86399) не ловится — тест берёт 8-дневные файлы, сдвиг в 1с на 7-дневном TTL ничего не меняет; тестировать 1с-точность на 7-дневном пороге бриттлно и не нужно.DO/ESCALATE нет. Все три правки раунда 1 закрыты, ключевая (allowlist) защищена мутационно-держащим golden-oracle; pickle убран, схема безопасно version-гейтится, гейт зелёный. Approve.
9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence). render-parity oracle проверен мной адверсариально (мутации venue + вложенного giveaway.description). Escalate нет.