Деплой (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.
62 lines
2 KiB
Python
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)
|