Review finding (low, doc-only): the rollback plan said each stage is one independently-revertable commit, but each stage is actually two commits on its branch (feature + review-round fix, the latter often fixing a real bug), so reverting a single commit would orphan the review-round fix. Reword the unit of rollback to the whole STAGE (branch/PR) and spell out the revert command per merge strategy: squash-merge -> revert the one squash commit; merge-commit -> revert -m 1 the merge; linear history -> revert the full range of the stage's commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 KiB
Стадия 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), чтобы поймать регрессии, которые проявляются только при взаимодействии стадий:
- 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.) /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, который может застопориться.- Дедуп + очистка при 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. - 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} (валидная подпись обязательна).
# 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 <squash-commit>откатывает её целиком, однозначно; - если стадия влита merge-коммитом —
git revert -m 1 <merge-commit>откатывает весь вклад ветки (оба коммита) разом; - если история линейная (rebase/fast-forward) — реверт всех коммитов стадии
(
git revert <feature>..<review-fix>включительно), а не одного.
- если стадия влита squash-merge (один коммит на стадию) —
- Стадии 1 и 2 — низкорисковые, деплоятся первыми; откатываются независимо от остальных.
- Стадия 3 (FileResponse) независима — реверт возвращает прежний ручной стриминг.
- Стадия 4 требует, чтобы 4.2 откатывалась не позже 4.3 (иначе рендер-в-потоке остался бы без безопасного пути сохранения media id) — откатывать стадию 4 целиком.
- Стадии 5 и 6 независимы, откатываются по отдельности.
- Стадия 7 — только тесты и этот документ: реверт ничего не меняет в проде.
Прод-деплой, живое наблюдение diag-логов и обновление прод-compose (healthcheck→/ping,
вне репозитория) — зона ответственности оператора (пункты 3–4 плана стадии 7).