# Стадия 7 — сквозная верификация плана стабилизации Дата: 2026-07-05. Ветка: `fix/stage-7-verification` (на базе `fix/stage-6-healthcheck`, кумулятивно содержит стадии 1–6). Эта стадия **ничего не меняет в поведении** прод-кода — только автоматизирует проверки и фиксирует, что должен наблюдать оператор после деплоя. ## Итог автотестов Каноничный прогон (изоляция `config` через `sys.modules`): `python -m pytest tests/`. ``` 260 passed ``` Раскладка по стадиям (все зелёные): | Стадия | Файл тестов | Кол-во | Что покрывает | |--------|-------------|--------|----------------| | 1 — анти-зависания | `tests/test_stage1_hangs.py` | 6 | таймаут RPC-гейта без утечки пермита; воркер переживает Exception/FloodWait, `task_done` сбалансирован; отмена в spacing-ожидании не теряет пермит | | 2 — статика/большие видео | `tests/test_stage2_static.py` | 15 | атомарный `_download_atomic` (publish-on-rename, чистка `.part.` при таймауте/zero-size/гонке); дедуп конкурентных скачиваний; FloodWait→429; touch mtime у `temp_*`; свипер чистит `.part.`+legacy `.tmp.`; баланс HTTP-семафора | | 3 — FileResponse | `tests/test_stage3_fileresponse.py` | 19 | матрица Range (0-499/500-/-500/за EOF→416/мусор→400/мульти-range→206 multipart); сохранены mtime-touch, delete_after-BackgroundTask, MIME-кэш; чистый ASGI-логгер | | 4 — гигиена event loop | `tests/test_stage4_eventloop.py` | 19 | ленивый `raw_message`; вынос side-effect IO из `process_message` (bulk upsert media id); рендер-пайплайн ушёл в поток без create_task/get_running_loop; XSS вычищен во всех 4 выводах ровно одним проходом | | 5 — батчинг SQLite | `tests/test_stage5_sqlite.py` | 8 | кэш-хит пишет в аккумулятор, а не в SQLite; flush→DB; snapshot-then-clear не теряет поздние апдейты; re-queue при сбое; str(channel)-ключи | | 6 — healthcheck | `tests/test_stage6_healthcheck.py` | 11 | `/ping` 200/503 по connected+age; ноль TG RPC; watchdog отключён→чистая проверка коннекта; `watchdog_last_ok_age()` | | 7 — интеграция | `tests/test_stage7_integration.py` | 8 | сквозные сценарии (см. ниже) | | — регрессия парсера | `tests/test_postparser_*.py` | 174 | заголовки/флаги/автор (существовавшие до плана) | ## Новые интеграционные тесты стадии 7 (DoD → сквозное доказательство) `tests/test_stage7_integration.py` драйвит **реальные точки входа** (`get_media`, `ping`, flush), чтобы поймать регрессии, которые проявляются только при взаимодействии стадий: 1. **Range на уровне маршрута `/media`** — `test_media_route_range_prefix_0_99`, `_range_suffix_last_100`, `_range_unsatisfiable_416`. Прогоняют `get_media` (кэш-хит) → `prepare_file_response` → `FileResponse` через реальный ASGI: `bytes=0-99`→206 с точным Content-Range/длиной и байтами, `bytes=-100`→206 (суффикс), `bytes=999999999-`→416 (`*/size`). Ловит: если кэш-хит перестанет доходить до FileResponse (ре-буферизация тела, ручные заголовки) или сломается связка digest-гейта — Range перестанет работать. (Стадия 3 пинит это на `prepare_file_response` напрямую; здесь — сшивка стадий 2+3.) 2. **`/ping` быстр и без RPC при висящей операции** — `test_ping_prompt_and_rpc_free_while_slow_op_pending` и `_reports_degraded_promptly_while_slow_op_pending`. Пока фейковая медленная корутина (модель зависшего hot-path) висит на `Event`, `ping()` возвращает 200/503 корректно и **до** завершения медленной операции, при нуле вызовов `get_me`/`safe_get_messages`. Ловит: рекаплинг `/ping` к TG RPC или к любому awaitable, который может застопориться. 3. **Дедуп + очистка при disconnect через реальный `get_media`** — `test_get_media_concurrent_shares_one_download_and_drains_registry` (2 конкурентных запроса → одна скачка, `_inflight` пуст) и `_request_cancel_does_not_stick_registry_or_hang_sibling` (отмена запроса-«клиента» не оставляет застрявший ключ и не вешает соседа). Ловит: возврат скачивания в корутину запроса (отмена убила бы download) или потерю `finally`-pop. 4. **str(channel)-ключ access-time end-to-end** — `test_media_cache_hit_flush_updates_str_keyed_db_row`. Кэш-хит `/media` записывает str-ключ, flush обновляет ту же строку в реальной SQLite (hit→flush→DB). Сшивает две половины: ключ, который пишет hot-path, и WHERE, по которому апдейтит flush. Ловит любое расхождение ключа аккумулятора и WHERE bulk-UPDATE (напр. перестановку колонок в SQL — проверено мутацией: тест краснеет, `added` остаётся stale). (int/str-хазард самого канала живёт в `download_media_file` и закрыт стадией 5 — `test_cache_hit_keys_channel_as_str`; здесь покрыта связка get_media+flush.) ## Соответствие DoD стадий → доказательство - **DoD 1** «ни один путь не ждёт TG без таймаута; воркер не умирает молча» → `test_stage1_hangs.py` (гейт-таймаут, живучесть воркера). Живая проверка supervision под нагрузкой — **наблюдение оператора** (лог `supervised_task_crashed/…_exited`). - **DoD 2** «клиенту никогда не отдаётся неполный файл; флуд→429; обрезки не живут >часа» → `test_stage2_static.py` целиком + интеграционный дедуп-тест стадии 7. - **DoD 3** «Range-тесты зелёные; поведение эндпоинта эквивалентно (± RFC-допущения)» → `test_stage3_fileresponse.py` + Range на уровне `/media` (стадия 7). «Нет потока-на-чанк» — **наблюдение оператора** (нагрузочный запрос большого файла, число io-потоков). - **DoD 4** «генерация 100-сообщ. фида не блокирует луп (параллельный /ping <100 мс); XSS зелёный; media id сохраняются» → `test_stage4_eventloop.py` (рендер в потоке, XSS, bulk upsert) + `/ping`-decoupling стадии 7. Живой замер «<100 мс на проде» — **наблюдение оператора**. - **DoD 5** «на кэш-хит /media ноль обращений к SQLite; фоновая запись раз в минуту» → `test_stage5_sqlite.py` + str-ключ end-to-end стадии 7. - **DoD 6** «во время зависшего TG RPC /ping отвечает мгновенно (503); контейнер не рестартится от медленного фида» → `test_stage6_healthcheck.py` + `/ping`-under-slow-op стадии 7. Отсутствие autoheal-рестартов на холодном кэше — **наблюдение оператора**. ## Ручные curl-сценарии для оператора (пост-деплой, локально в контейнере) Подставить реальный `{channel}/{post_id}/{fid}/{digest}` (валидная подпись обязательна). ```bash # 1. Range-тройка (ожидания: 206 / 206 / 416) curl -s -D- -o /dev/null -H "Range: bytes=0-99" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}" curl -s -D- -o /dev/null -H "Range: bytes=-100" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}" curl -s -D- -o /dev/null -H "Range: bytes=999999999-" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}" # 2. Параллельные запросы одного БОЛЬШОГО видео (>100 MB), пока не в кэше: # оба должны получить ПОЛНЫЙ файл (одинаковый размер), без частичной отдачи. URL="http://127.0.0.1:80/media/{ch}/{pid}/{bigfid}/{digest}" curl -s -o /tmp/a "$URL" & curl -s -o /tmp/b "$URL" & wait ls -l /tmp/a /tmp/b # размеры совпадают и равны полному файлу; на диске нет *.part.* # 3. Обрыв на середине стрима — нет утечки тасков/фд: # считать fd до/после и убедиться, что не растут монотонно. lsof -p $(pgrep -f api_server) | wc -l # baseline timeout 2 curl -s -o /dev/null "$URL"; sleep 5 # оборвать скачку на середине (несколько раз) lsof -p $(pgrep -f api_server) | wc -l # не выросло относительно baseline # 4. Генерация большого фида + параллельный /ping (<100 мс во время генерации): curl -s -o /dev/null "http://127.0.0.1:80/rss/{big_channel}" & for i in $(seq 1 20); do curl -s -o /dev/null -w "%{time_total}\n" "http://127.0.0.1:80/ping"; done wait # все замеры /ping должны быть заметно < 0.100 s ``` > Сценарии 2 и 3 (реальная скачка большого видео + реальный подсчёт fd через `lsof`) в > headless-тестах **намеренно не подделаны** — им нужен живой сервер, реальные загрузки и > реальные файловые дескрипторы. Дедуп-инвариант и disconnect-очистка доказаны на уровне > реестра/`get_media` (тесты стадии 7 №3), но «нет утечки fd на проде» проверяет оператор. ## Diag-логи для наблюдения после деплоя Ожидаемая динамика (сравнить с до-деплойным baseline): - `diag_semaphore_wait` — ожидание HTTP-семафора должно **упасть** (реже/меньше секунд). - `diag_download_timing` — время скачивания стабилизируется; нет длинных «зависаний». - `diag_sanitize_slow` — почти **исчезнуть** (один sanitize на выходную границу, стадия 4). - `watchdog: heartbeat` — **продолжаются** штатно (живость TG-сессии). - На момент FloodWait — **нет всплеска 404** на `/media`; вместо этого `media_flood_wait` и ответы **429** с `Retry-After` (стадия 2.3). - Признаки supervision: любые `supervised_task_crashed` / `supervised_task_exited` на CRITICAL — сигнал разобраться, но задача при этом перезапускается (не тихая смерть). ## План отката (rollback) Единица отката — **стадия целиком**, не отдельный коммит. Каждая стадия живёт на своей ветке/PR (`fix/stage-1-hangs` … `fix/stage-7-verification`) и, как правило, состоит из **двух коммитов**: feature-коммит + фикс review-раунда (последний нередко чинит реальный баг — напр. fail-closed XSS в стадии 4, watchdog-gate в стадии 6). Поэтому `git revert` одного коммита осиротит фикс review-раунда и даст несогласованный откат. Откатывать нужно на гранулярности стадии; порядок стадий = порядок деплоя. - **Как откатывать стадию** — зависит от того, как ветки влиты в `fix/stability`/`main`: - если стадия влита **squash-merge** (один коммит на стадию) — `git revert ` откатывает её целиком, однозначно; - если стадия влита **merge-коммитом** — `git revert -m 1 ` откатывает весь вклад ветки (оба коммита) разом; - если история линейная (rebase/fast-forward) — реверт **всех** коммитов стадии (`git revert ..` включительно), а не одного. - Стадии 1 и 2 — низкорисковые, деплоятся первыми; откатываются независимо от остальных. - Стадия 3 (FileResponse) независима — реверт возвращает прежний ручной стриминг. - Стадия 4 требует, чтобы 4.2 откатывалась не позже 4.3 (иначе рендер-в-потоке остался бы без безопасного пути сохранения media id) — откатывать стадию 4 целиком. - Стадии 5 и 6 независимы, откатываются по отдельности. - Стадия 7 — только тесты и этот документ: реверт ничего не меняет в проде. Прод-деплой, живое наблюдение diag-логов и обновление прод-compose (healthcheck→`/ping`, вне репозитория) — **зона ответственности оператора** (пункты 3–4 плана стадии 7).