Кеши, PR-юнит 2 (пакет C): единая канонизация ключа канала + one-shot миграция данных #24

Open
opened 2026-07-05 19:15:49 +03:00 by agent_vscode · 1 comment
Collaborator

ТЗ: канонизация ключа канала

Часть принятой спеки plans/cache-optimization-spec.md. Зависимость: строго после #23 (задание 9.1 использует _cache_file_path, создаваемый там). Один PR. Комментарии в коде — только английские.

Проблема

Один канал живёт под ключами Durov / durov / @name в трёх слоях (tgcache-файлы, каталоги data/cache/, строки SQLite) → дубли кеша, двойные походы в Telegram, осиротевшие деревья. Унификация ID↔username — сознательная НЕ-цель (потребовала бы резолва через get_chat): /rss/-100… и /rss/durov остаются разными ключами.

Задание 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()

Задание 9 — применить ключ во всех трёх слоях

  1. tg_cache.py: в _cache_file_path(key, suffix) прогонять ключ через canonical_channel_key.
  2. post_parser.get_channel_username (~1081–1097): возвращать username в нижнем регистре (обе ветки — usernames-список и одиночный username). Это канонизирует записи SQLite, генерируемые media-URL и t.me-ссылки (t.me к регистру нечувствителен).
  3. api_server.get_media (~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 канонический идентификатор безопасен). Digest проверять по ИСХОДНОЙ строке URL — иначе сломаются все выданные ссылки.

Задание 10 — one-shot миграция существующих данных

migrate_channel_keys_sync(db_path: str, cache_dir: str) -> None. Вызов — в lifespan после init_db_sync, ДО client.start() и до фоновых задач, строго await asyncio.to_thread(...) (sync-функция: FS-rename + SQLite; голый вызов заблокирует event loop).

Единица работы — канал целиком, сначала 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) and os.path.samefile(src, dst)no-op FS-шага, перейти к SQL-шагу. На case-insensitive FS (macOS/APFS, Docker Desktop for Mac) 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. Итоговая сводка одной строкой: migration_summary: rows merged N, dirs renamed M, samefile no-ops K, failures F.

Задание 11 — тесты

  • Юнит: 'Durov'→'durov', '@durov'→'durov', -1001… (int и str) → '-1001…'.
  • tgcache-путь одинаков для Durov/durov; get_channel_username с username='MixedCase''mixedcase'.
  • Миграция: (а) SQL-merge обеих форм → одна строка с max(added) и non-NULL mime_type; (б) samefile-guard → no-op, данные целы; (в) merge двух реально разных каталогов → файлы объединены, target выигрывает; (г) повторный запуск — no-op.

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

  • /rss/Durov и /rss/durov — один tgcache-файл; /media/Durov/... и /media/durov/... — один файл на диске.
  • Миграция на case-insensitive FS — no-op без потери данных.
  • pytest зелёный; правки post_parser.py — только lowercase в get_channel_username.

В описание PR обязательно

  • Остаточные старорегистровые строки/каталоги, не покрытые миграцией, доживают до 20-дневного вытеснения сами.
  • Миграция односторонняя (reverse нет): откат PR после её выполнения повторяет re-download-сценарий зеркально (ограниченный, самоизлечивается за 20 дней) — ожидаемый признак отката, не авария.
# ТЗ: канонизация ключа канала Часть принятой спеки `plans/cache-optimization-spec.md`. **Зависимость: строго после #23** (задание 9.1 использует `_cache_file_path`, создаваемый там). Один PR. Комментарии в коде — только английские. ## Проблема Один канал живёт под ключами `Durov` / `durov` / `@name` в трёх слоях (tgcache-файлы, каталоги `data/cache/`, строки SQLite) → дубли кеша, двойные походы в Telegram, осиротевшие деревья. Унификация ID↔username — сознательная НЕ-цель (потребовала бы резолва через `get_chat`): `/rss/-100…` и `/rss/durov` остаются разными ключами. ## Задание 8 — новый модуль `channel_key.py` Отдельный модуль без зависимостей (импортируют `tg_cache`, `post_parser`, `api_server` — исключает циклические импорты): ```python 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() ``` ## Задание 9 — применить ключ во всех трёх слоях 1. `tg_cache.py`: в `_cache_file_path(key, suffix)` прогонять ключ через `canonical_channel_key`. 2. `post_parser.get_channel_username` (~1081–1097): возвращать username в нижнем регистре (обе ветки — `usernames`-список и одиночный `username`). Это канонизирует записи SQLite, генерируемые media-URL и t.me-ссылки (t.me к регистру нечувствителен). 3. `api_server.get_media` (~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 канонический идентификатор безопасен). **Digest проверять по ИСХОДНОЙ строке URL** — иначе сломаются все выданные ссылки. ## Задание 10 — one-shot миграция существующих данных `migrate_channel_keys_sync(db_path: str, cache_dir: str) -> None`. Вызов — в `lifespan` **после `init_db_sync`, ДО `client.start()` и до фоновых задач**, строго `await asyncio.to_thread(...)` (sync-функция: FS-rename + SQLite; голый вызов заблокирует event loop). Единица работы — **канал целиком, сначала 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) and os.path.samefile(src, dst)` → **no-op FS-шага, перейти к SQL-шагу**. На case-insensitive FS (macOS/APFS, Docker Desktop for Mac) `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. Итоговая сводка одной строкой: `migration_summary: rows merged N, dirs renamed M, samefile no-ops K, failures F`. ## Задание 11 — тесты - Юнит: `'Durov'→'durov'`, `'@durov'→'durov'`, `-1001…` (int и str) → `'-1001…'`. - tgcache-путь одинаков для `Durov`/`durov`; `get_channel_username` с `username='MixedCase'` → `'mixedcase'`. - Миграция: (а) SQL-merge обеих форм → одна строка с `max(added)` и non-NULL `mime_type`; (б) samefile-guard → no-op, данные целы; (в) merge двух реально разных каталогов → файлы объединены, target выигрывает; (г) повторный запуск — no-op. ## Критерии приёмки - `/rss/Durov` и `/rss/durov` — один tgcache-файл; `/media/Durov/...` и `/media/durov/...` — один файл на диске. - Миграция на case-insensitive FS — no-op без потери данных. - `pytest` зелёный; правки `post_parser.py` — только lowercase в `get_channel_username`. ## В описание PR обязательно - Остаточные старорегистровые строки/каталоги, не покрытые миграцией, доживают до 20-дневного вытеснения сами. - **Миграция односторонняя** (reverse нет): откат PR после её выполнения повторяет re-download-сценарий зеркально (ограниченный, самоизлечивается за 20 дней) — ожидаемый признак отката, не авария.
Author
Collaborator

Пересечение с эпиком рефакторинга рендера (#34)

Задание 9.2 меняет post_parser.get_channel_username → возвращать username в нижнем регистре. Два следствия для рендер-эпика:

  1. Golden рендера (#27) придётся переснять. get_channel_username питает подписанные media-URL (/media/{channel}/...), t.me-ссылки и «Open in Telegram/Web» в футере. Lowercase изменит байты фида для любого канала со смешанным регистром username. Кто из эпиков мёржится вторым — переснимает feed-golden с пометкой (для рендера это НЕ пункт реестра §3, а внешнее изменение из #24; зафиксировать в PR обоих сторон).

  2. Совместное редактирование post_parser.py. #24 правит get_channel_username; этапы рендера 2 (#29) и 5 (#32) правят соседние функции. Конкретно get_channel_username рефактор рендера НЕ трогает — конфликт только текстовый (merge), не семантический. Кто второй — ребейзит.

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

## Пересечение с эпиком рефакторинга рендера (#34) Задание 9.2 меняет `post_parser.get_channel_username` → возвращать username в нижнем регистре. Два следствия для рендер-эпика: 1. **Golden рендера (#27) придётся переснять.** `get_channel_username` питает подписанные media-URL (`/media/{channel}/...`), t.me-ссылки и «Open in Telegram/Web» в футере. Lowercase изменит байты фида для любого канала со смешанным регистром username. Кто из эпиков мёржится вторым — переснимает feed-golden с пометкой (для рендера это НЕ пункт реестра §3, а внешнее изменение из #24; зафиксировать в PR обоих сторон). 2. **Совместное редактирование `post_parser.py`.** #24 правит `get_channel_username`; этапы рендера 2 (#29) и 5 (#32) правят соседние функции. Конкретно `get_channel_username` рефактор рендера НЕ трогает — конфликт только текстовый (merge), не семантический. Кто второй — ребейзит. Связано: #34, #27, #32.
Sign in to join this conversation.