cff6e61d2f
Caps the stacked stability work (stages 1-6). Verification only — ZERO production code change (git diff against fix/stage-6-healthcheck touches only these two files). - tests/test_stage7_integration.py (8 cross-stage integration tests via TestClient + a mocked TelegramClient, no network): Range at the /media route level (206/206/416 through get_media -> prepare_file_response -> FileResponse); /ping stays prompt and issues zero TG RPC while a slow op is parked; in-flight dedup shares one download and drains _inflight after completion AND after request cancellation (no stuck key / hung waiter); str(channel) access-time hit -> flush -> the bulk UPDATE lands on the seeded row (mutation-verified: transposing the key columns reds it). - docs/stability-verification.md: per-stage DoD -> evidence mapping (test or "operator observation" for prod-only items), the exact manual curl/lsof scenarios for the operator to run post-deploy, the diag-log signals to watch, and the per-stage independent-commit rollback plan. Full integrated suite: 260 passed (252 baseline + 8). The prod deploy + live diag-log observation (plan items 3-4) are the operator's call — this stage does not deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
14 KiB
Markdown
139 lines
14 KiB
Markdown
# Стадия 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)
|
|
|
|
Каждая стадия — отдельный, независимо ревертируемый коммит на своей ветке
|
|
(`fix/stage-1-hangs` … `fix/stage-6-healthcheck`); порядок стадий = порядок деплоя.
|
|
|
|
- Стадии 1 и 2 — низкорисковые, деплоятся первыми; при регрессе откатываются по одной
|
|
(`git revert <commit>` соответствующей стадии) без затрагивания остальных.
|
|
- Стадия 3 (FileResponse) независима — реверт возвращает прежний ручной стриминг.
|
|
- Стадия 4 требует, чтобы 4.2 откатывалась не позже 4.3 (иначе рендер-в-потоке остался бы
|
|
без безопасного пути сохранения media id) — откатывать стадию 4 целиком.
|
|
- Стадии 5 и 6 независимы, откатываются по отдельности.
|
|
- Стадия 7 — только тесты и этот документ: реверт ничего не меняет в проде.
|
|
|
|
Прод-деплой, живое наблюдение diag-логов и обновление прод-compose (healthcheck→`/ping`,
|
|
вне репозитория) — **зона ответственности оператора** (пункты 3–4 плана стадии 7).
|