Кеши, PR-юнит 1 (пакеты A+B+F): снапшот-кеш истории вместо pickle, префиксный limit, джиттер при записи + age-sweep #23

Open
opened 2026-07-05 19:15:09 +03:00 by agent_vscode · 2 comments
Collaborator

ТЗ: переработка кеша истории (tg_cache.py)

Часть принятой спеки plans/cache-optimization-spec.md (адверсарное ревью — 4 раунда, возражений нет). Ишью самодостаточно: всё нормативное содержимое — ниже. Один PR на всё ишью. Комментарии в коде — только английские. Никаких правок вне описанного объёма.

Цель

Кеш истории (data/tgcache/) перестаёт хранить pickle живых объектов pyrogram. При записи из Message извлекается JSON-словарь по явному allowlist (снапшот); при чтении восстанавливается duck-typed CachedMessage, неотличимый для конвейера рендеринга от Message. post_parser.py и rss_generator.py не меняются вообще — главный инвариант.

Задание 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]: ...

Схема снапшота v1 (контракт; сверена с kurigram 2.2.23)

{
  "id": 123,
  "date": "2026-07-05T12:00:00",      // isoformat or null; restores naive/aware exactly
  "text":    {"plain": "...", "html": "..."},   // null if absent
  "caption": {"plain": "...", "html": "..."},
  "media": "PHOTO",                   // MessageMediaType.name or null
  "service": "PINNED_MESSAGE",        // MessageServiceType.name or null; restored as plain string
  "media_group_id": 456,
  "views": 789,
  "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": [
      {"emoji": "👍", "count": 5, "is_paid": null, "custom_emoji_id": null},
      // custom-emoji: emoji null, custom_emoji_id is a STRING (kurigram: str(document_id));
      // restored with the FULL key set, like live objects:
      {"emoji": null, "count": 2, "is_paid": null, "custom_emoji_id": "987..."}
  ],
  "poll": {"question": "...", "options": [{"text": "..."}]},  // FormattedText unwrapped, см. тонкость 1
  "web_page": {"type": "...", "url": "...", "display_url": "...", "site_name": "...",
               "title": "...", "description": "...", "has_large_media": false,
               "photo": {"file_unique_id": "..."}},
  "photo":      {"file_unique_id": "..."},
  "video":      {"file_unique_id": "...", "file_size": 123},
  "document":   {"file_unique_id": "...", "mime_type": "..."},
  "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}
}

Правила снапшота/восстановления

  • Каждое поле — через getattr(..., None).
  • Styled-text: в kurigram 2.2.23 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 вычисляется при записи (у живого pyrogram Str); если .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.
  • Вложенные объекты восстанавливаются с ПОЛНЫМ набором ключей схемы (None-дефолты) — «как живые» (у живых pyrogram-объектов все атрибуты всегда выставлены в __init__; код обращается к ним и напрямую: rss_generator.py ~131 message.chat.username в except-хендлере, post_parser.py ~1087 u.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 — режим «только записанные ключи».
  • Дефолты top-level атрибутов 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-store

  1. Убрать import pickle полностью; удалить legacy-ветку double-pickle.
  2. Generic-пара вместо двух дублирующихся троек функций:
def _store_entry(path: str, payload: dict) -> None:
    """Atomically write {'version', 'timestamp', 'jitter', **payload} as JSON.
    Writes to a unique '<path>.tmp.<uuid4().hex>' 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, 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)}.
  3. _save_chat_to_cache/_get_chat_from_cache — тот же store, payload = {'data': {...}}.
  4. cleanup_legacy_cache_files() -> int: удаляет из CACHE_DIR *.cache и *.chatinfo (включая *_history.cache); ошибки — warning.
  5. Сигнатуры/поведение cached_get_chat_history/cached_get_chat не меняются: miss → живые Message, hit → CachedMessage.

Задание 6 (пакет B) — префикс вместо строгого равенства limit

В _get_history_from_cache (сообщения лежат от новых к старым):

cached_limit = payload.get('limit', 0)
raw_messages = payload['messages']
# Serve when fetched with an equal-or-larger limit, OR when the channel is exhausted
# (fewer messages exist than were asked for -> cache holds the entire recent history).
if cached_limit < limit and len(raw_messages) >= cached_limit:
    return None
return restore_messages(raw_messages[:limit])

_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-данные

  1. sweep_tgcache(max_age_days: int = 7) -> int в tg_cache.py: удаляет из CACHE_DIR файлы с mtime старше порога (мёртвые каналы, осиротевшие uuid-tmp). Гонка с писателем ВОЗМОЖНА (stat→unlink не атомарен против os.replace), но безвредна — худший исход один лишний miss; НЕ выдавать за инвариант атомарности в комментариях.
  2. Вызовы: (а) один раз при старте; (б) из цикла cache_media_files раз в проход, обязательно через asyncio.to_thread.
  3. Задание 3: в 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) и верификация

  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.dumps снапшота не падает; восстановленный poll.question — строка; getattr(option, 'text', '') == "Opt". Тест ОБЯЗАН падать, если снапшот кладёт FormattedText как есть.
  3. Реакции: обычная/paid/custom. У custom: hasattr(r, 'emoji') is True, r.emoji is None, r.custom_emoji_id — строка.
  4. forward_origin Case 1–5: hasattr-ветвление выбирает правильный case (presence-семантика).
  5. 'PINNED_MESSAGE' in str(restored.service).
  6. Мутабельность + deepcopy (.text.html переживает deepcopy).
  7. Неизвестный media name ("FUTURE_TYPE") → media is None, без исключения.
  8. Store: round-trip, TTL, version mismatch → None, битый JSON → None; уникальность tmp-путей: перехватить os.replace (или замокать uuid.uuid4) и проверить, что два вызова _store_entry в один path использовали РАЗНЫЕ tmp-имена (одиночный «конкурентный» прогон гонку не ловит); cleanup_legacy_cache_files не трогает *.history.json; sweep_tgcache удаляет только старые.
  9. _save_media_file_ids на восстановленном сообщении с video.file_size > 100 МБ ничего не добавляет в _pending_media_ids.
  10. Восстановленный chat без username: message.chat.username is None (не AttributeError).
  11. Префикс (пакет B): кеш limit=100 → запрос 50 — хит (50 первых, порядок); кеш 50 → запрос 100 — промах; кеш limit=100 при 37 сообщениях → запрос 200 — хит (37); TTL работает независимо.
  12. Джиттер: запись у границы TTL даёт стабильный результат при повторных чтениях.

Критерии приёмки

  • pytest целиком зелёный; post_parser.py и rss_generator.py НЕ изменены.
  • grep pickle tg_cache.py — пусто.
  • Сценарий «RSS(limit=100) → HTML(limit=50) → RSS(100)» — один поход в Telegram (сейчас три).
  • Кеш истории сохраняется для каналов с опросами (снапшот JSON-сериализуем).

Примечания

  • Миграции старых pickle нет: старые файлы = miss (TTL: история 8 ч, chatinfo 8–12 ч), чистка удалит их при старте.
  • Смешение живых (miss) и восстановленных (hit) сообщений в одном списке невозможно: hit/miss атомарен на весь список.
  • Строгое сравнение limit УЖЕ заменено префиксом в этом же PR (задание 6), джиттер — задание 17: не реализовывать промежуточные версии.
# ТЗ: переработка кеша истории (`tg_cache.py`) Часть принятой спеки `plans/cache-optimization-spec.md` (адверсарное ревью — 4 раунда, возражений нет). Ишью самодостаточно: всё нормативное содержимое — ниже. Один PR на всё ишью. Комментарии в коде — **только английские**. Никаких правок вне описанного объёма. ## Цель Кеш истории (`data/tgcache/`) перестаёт хранить pickle живых объектов pyrogram. При записи из `Message` извлекается JSON-словарь по явному allowlist (снапшот); при чтении восстанавливается duck-typed `CachedMessage`, неотличимый для конвейера рендеринга от `Message`. **`post_parser.py` и `rss_generator.py` не меняются вообще** — главный инвариант. ## Задание 1 — новый модуль `message_snapshot.py` ```python SNAPSHOT_VERSION = 1 class CachedStr(str): """str subclass carrying a precomputed .html rendering, mirroring pyrogram's Str. deepcopy/pickle preserve the instance __dict__, so .html survives copying.""" # factory: CachedStr.build(plain, html) class CachedMessage: """Duck-typed stand-in for pyrogram Message, restored from a snapshot dict. Mutable (reply enrichment assigns .reply_to_message). All pipeline-consumed top-level attributes exist with None/False defaults, so getattr never raises.""" # __str__/__repr__: json.dumps of the snapshot dict, default=str def snapshot_message(message) -> dict: ... def restore_message(data: dict) -> CachedMessage: ... def snapshot_messages(messages) -> list[dict]: ... def restore_messages(items: list[dict]) -> list[CachedMessage]: ... ``` ### Схема снапшота v1 (контракт; сверена с kurigram 2.2.23) ```jsonc { "id": 123, "date": "2026-07-05T12:00:00", // isoformat or null; restores naive/aware exactly "text": {"plain": "...", "html": "..."}, // null if absent "caption": {"plain": "...", "html": "..."}, "media": "PHOTO", // MessageMediaType.name or null "service": "PINNED_MESSAGE", // MessageServiceType.name or null; restored as plain string "media_group_id": 456, "views": 789, "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": [ {"emoji": "👍", "count": 5, "is_paid": null, "custom_emoji_id": null}, // custom-emoji: emoji null, custom_emoji_id is a STRING (kurigram: str(document_id)); // restored with the FULL key set, like live objects: {"emoji": null, "count": 2, "is_paid": null, "custom_emoji_id": "987..."} ], "poll": {"question": "...", "options": [{"text": "..."}]}, // FormattedText unwrapped, см. тонкость 1 "web_page": {"type": "...", "url": "...", "display_url": "...", "site_name": "...", "title": "...", "description": "...", "has_large_media": false, "photo": {"file_unique_id": "..."}}, "photo": {"file_unique_id": "..."}, "video": {"file_unique_id": "...", "file_size": 123}, "document": {"file_unique_id": "...", "mime_type": "..."}, "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} } ``` ### Правила снапшота/восстановления - Каждое поле — через `getattr(..., None)`. - **Styled-text**: в kurigram 2.2.23 `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` вычисляется при записи (у живого pyrogram `Str`); если `.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. - **Вложенные объекты восстанавливаются с ПОЛНЫМ набором ключей схемы (None-дефолты) — «как живые»** (у живых pyrogram-объектов все атрибуты всегда выставлены в `__init__`; код обращается к ним и напрямую: `rss_generator.py` ~131 `message.chat.username` в except-хендлере, `post_parser.py` ~1087 `u.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 — режим «только записанные ключи». - Дефолты top-level атрибутов `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-store 1. Убрать `import pickle` полностью; удалить legacy-ветку double-pickle. 2. Generic-пара вместо двух дублирующихся троек функций: ```python def _store_entry(path: str, payload: dict) -> None: """Atomically write {'version', 'timestamp', 'jitter', **payload} as JSON. Writes to a unique '<path>.tmp.<uuid4().hex>' 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, JSON error.""" ``` 3. Пути: `<safe_key>.history.json` / `<safe_key>.chatinfo.json`, общий помощник `_cache_file_path(key, suffix)`. 4. `_save_history_to_cache`: `payload = {'limit': limit, 'messages': snapshot_messages(messages)}`. 5. `_save_chat_to_cache`/`_get_chat_from_cache` — тот же store, `payload = {'data': {...}}`. 6. `cleanup_legacy_cache_files() -> int`: удаляет из `CACHE_DIR` `*.cache` и `*.chatinfo` (включая `*_history.cache`); ошибки — warning. 7. Сигнатуры/поведение `cached_get_chat_history`/`cached_get_chat` не меняются: miss → живые `Message`, hit → `CachedMessage`. ## Задание 6 (пакет B) — префикс вместо строгого равенства `limit` В `_get_history_from_cache` (сообщения лежат от новых к старым): ```python cached_limit = payload.get('limit', 0) raw_messages = payload['messages'] # Serve when fetched with an equal-or-larger limit, OR when the channel is exhausted # (fewer messages exist than were asked for -> cache holds the entire recent history). if cached_limit < limit and len(raw_messages) >= cached_limit: return None return restore_messages(raw_messages[:limit]) ``` `_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-данные 1. `sweep_tgcache(max_age_days: int = 7) -> int` в `tg_cache.py`: удаляет из `CACHE_DIR` файлы с mtime старше порога (мёртвые каналы, осиротевшие uuid-tmp). Гонка с писателем ВОЗМОЖНА (stat→unlink не атомарен против os.replace), но безвредна — худший исход один лишний miss; НЕ выдавать за инвариант атомарности в комментариях. 2. Вызовы: (а) один раз при старте; (б) из цикла `cache_media_files` раз в проход, **обязательно через `asyncio.to_thread`**. 3. Задание 3: в `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`) и верификация 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.dumps` снапшота не падает; восстановленный `poll.question` — строка; `getattr(option, 'text', '') == "Opt"`. Тест ОБЯЗАН падать, если снапшот кладёт FormattedText как есть. 3. Реакции: обычная/paid/custom. У custom: `hasattr(r, 'emoji') is True`, `r.emoji is None`, `r.custom_emoji_id` — строка. 4. forward_origin Case 1–5: hasattr-ветвление выбирает правильный case (presence-семантика). 5. `'PINNED_MESSAGE' in str(restored.service)`. 6. Мутабельность + deepcopy (`.text.html` переживает deepcopy). 7. Неизвестный media name (`"FUTURE_TYPE"`) → `media is None`, без исключения. 8. Store: round-trip, TTL, version mismatch → None, битый JSON → None; уникальность tmp-путей: перехватить `os.replace` (или замокать `uuid.uuid4`) и проверить, что два вызова `_store_entry` в один path использовали РАЗНЫЕ tmp-имена (одиночный «конкурентный» прогон гонку не ловит); `cleanup_legacy_cache_files` не трогает `*.history.json`; `sweep_tgcache` удаляет только старые. 9. `_save_media_file_ids` на восстановленном сообщении с video.file_size > 100 МБ ничего не добавляет в `_pending_media_ids`. 10. Восстановленный chat без username: `message.chat.username is None` (не AttributeError). 11. Префикс (пакет B): кеш limit=100 → запрос 50 — хит (50 первых, порядок); кеш 50 → запрос 100 — промах; кеш limit=100 при 37 сообщениях → запрос 200 — хит (37); TTL работает независимо. 12. Джиттер: запись у границы TTL даёт стабильный результат при повторных чтениях. ## Критерии приёмки - `pytest` целиком зелёный; `post_parser.py` и `rss_generator.py` НЕ изменены. - `grep pickle tg_cache.py` — пусто. - Сценарий «RSS(limit=100) → HTML(limit=50) → RSS(100)» — один поход в Telegram (сейчас три). - Кеш истории сохраняется для каналов с опросами (снапшот JSON-сериализуем). ## Примечания - Миграции старых pickle нет: старые файлы = miss (TTL: история 8 ч, chatinfo 8–12 ч), чистка удалит их при старте. - Смешение живых (miss) и восстановленных (hit) сообщений в одном списке невозможно: hit/miss атомарен на весь список. - Строгое сравнение limit УЖЕ заменено префиксом в этом же PR (задание 6), джиттер — задание 17: не реализовывать промежуточные версии.
Author
Collaborator

Полный сет работ по кешам (спека plans/cache-optimization-spec.md, принята 2026-07-05):

PR-юнит Ишью Зависимость
1: пакеты A+B+F (снапшот-кеш, префиксный limit, джиттер+sweep) этот, #23 — стартовый
2: пакет C (канонизация ключа + миграция) #24 строго после #23
3: пакет D (свипер медиа-кеша) #25 независим, параллельно
4: пакет E (пути + MIME-кеш) #26 независим, параллельно

Рекомендуемый порядок: #23#24; #25 и #26 — в любой момент параллельно.

Полный сет работ по кешам (спека `plans/cache-optimization-spec.md`, принята 2026-07-05): | PR-юнит | Ишью | Зависимость | |---|---|---| | 1: пакеты A+B+F (снапшот-кеш, префиксный limit, джиттер+sweep) | этот, #23 | — стартовый | | 2: пакет C (канонизация ключа + миграция) | #24 | строго после #23 | | 3: пакет D (свипер медиа-кеша) | #25 | независим, параллельно | | 4: пакет E (пути + MIME-кеш) | #26 | независим, параллельно | Рекомендуемый порядок: #23 → #24; #25 и #26 — в любой момент параллельно.
Author
Collaborator

⚠️ Пересечение с эпиком рефакторинга рендера (#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-hit getattr(message, 'giveaway', None) вернёт None:

  • инфо-блоки giveaway/checklist/contact/location/venue/dice/game молча исчезнут (media-enum сохранён → заголовок «🎁 Giveaway» есть, а блока нет);
  • PAID_MEDIA отрисуется как « Paid media (0 stars, 0 item(s))»;
  • poll с description_media потеряет картинку И перевернёт флаг no_image.

На cache-miss (живой Message) всё рендерится верно → дивергенция hit/miss, невидимая для golden рендера (#27 реплеит настоящий pickle Message, а прод после этого 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/) остаётся валидным входом (реплеится через monkeypatch cached_get_chat_history), но проверять он будет Message, а не CachedMessage — поэтому пункт 2 обязателен.

Сиквенсинг

Рекомендация: этот PR (#23) authored ПОСЛЕ фиксации таблицы MEDIA_SOURCES (#32, этап 5a) — тогда аллоулист снапшота выводится из неё, а не переоткрывается вручную. Если #23 идёт раньше — аллоулист всё равно обязан покрыть полный список полей выше, и это надо зафиксировать явно.

Связано: #34, #27, #32.

## ⚠️ Пересечение с эпиком рефакторинга рендера (#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-**hit** `getattr(message, 'giveaway', None)` вернёт `None`: - инфо-блоки giveaway/checklist/contact/location/venue/dice/game **молча исчезнут** (media-enum сохранён → заголовок «🎁 Giveaway» есть, а блока нет); - PAID_MEDIA отрисуется как «⭐ Paid media (0 stars, 0 item(s))»; - poll с `description_media` потеряет картинку И перевернёт флаг `no_image`. На cache-**miss** (живой `Message`) всё рендерится верно → дивергенция hit/miss, невидимая для golden рендера (#27 реплеит настоящий pickle `Message`, а прод после этого 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/`) остаётся валидным входом (реплеится через monkeypatch `cached_get_chat_history`), но проверять он будет `Message`, а не `CachedMessage` — поэтому пункт 2 обязателен. ## Сиквенсинг Рекомендация: этот PR (#23) authored ПОСЛЕ фиксации таблицы `MEDIA_SOURCES` (#32, этап 5a) — тогда аллоулист снапшота выводится из неё, а не переоткрывается вручную. Если #23 идёт раньше — аллоулист всё равно обязан покрыть полный список полей выше, и это надо зафиксировать явно. Связано: #34, #27, #32.
Sign in to join this conversation.