gendesign/tradein-mvp/backend/tests/core/test_shutdown.py
bot-backend 848e6d3bdd feat(tradein/scraper): cooperative SIGTERM-drain for graceful deploy (#1182 Phase 2)
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.

Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.

- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
  / shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
  добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
  hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
  reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
  pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
  (partial), не mark_cancelled; user-cancel семантика без изменений.

Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').

Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
2026-06-28 19:52:09 +03:00

62 lines
2 KiB
Python

"""Тесты для app/core/shutdown.py — кооперативный SIGTERM-флаг (#1182 Phase 2)."""
from __future__ import annotations
import asyncio
import pytest
from app.core import shutdown as sd
@pytest.fixture(autouse=True)
def _reset() -> None:
"""Event — module-global: сбрасываем до и после каждого теста для изоляции."""
sd.reset_shutdown()
yield
sd.reset_shutdown()
def test_default_not_requested() -> None:
"""По умолчанию shutdown не запрошен."""
assert sd.shutdown_requested() is False
def test_request_sets_flag() -> None:
"""request_shutdown() выставляет флаг."""
sd.request_shutdown()
assert sd.shutdown_requested() is True
def test_request_is_idempotent() -> None:
"""Повторный request_shutdown() безвреден — флаг остаётся True."""
sd.request_shutdown()
sd.request_shutdown()
assert sd.shutdown_requested() is True
def test_reset_clears_flag() -> None:
"""reset_shutdown() (test-only) снимает флаг."""
sd.request_shutdown()
assert sd.shutdown_requested() is True
sd.reset_shutdown()
assert sd.shutdown_requested() is False
@pytest.mark.asyncio
async def test_wait_for_shutdown_blocks_until_requested() -> None:
"""wait_for_shutdown() висит, пока не вызван request_shutdown()."""
waiter = asyncio.create_task(sd.wait_for_shutdown())
await asyncio.sleep(0.02)
assert not waiter.done(), "не должен завершиться до request_shutdown()"
sd.request_shutdown()
await asyncio.wait_for(waiter, timeout=1.0)
assert waiter.done()
@pytest.mark.asyncio
async def test_wait_for_shutdown_returns_immediately_if_already_set() -> None:
"""Если флаг уже выставлен, wait_for_shutdown() возвращается сразу."""
sd.request_shutdown()
await asyncio.wait_for(sd.wait_for_shutdown(), timeout=1.0)