Деплой (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.
48 lines
2.7 KiB
Python
48 lines
2.7 KiB
Python
"""Кооперативный shutdown-флаг для scraper-планировщика (#1182 Phase 2).
|
||
|
||
Зачем: деплой (docker recreate `tradein-scraper`) шлёт SIGTERM с
|
||
`stop_grace_period=120s`. Раньше `scheduler_main` отвечал hard `task.cancel()` →
|
||
бегущий scrape-unit получал CancelledError посреди браузер-карточки → карточка
|
||
гибла без COMMIT. Теперь SIGTERM лишь ВЫСТАВЛЯЕТ этот флаг; tick-loop планировщика
|
||
и длинные задачи опрашивают `shutdown_requested()` на СВОИХ существующих
|
||
between-unit checkpoint'ах, докоммичивают текущий unit и выходят сами.
|
||
|
||
Модуль намеренно standalone (зависит только от `asyncio`): планировщик импортирует
|
||
задачи, а задачи должны читать флаг — общий нижний слой без app-зависимостей
|
||
исключает циклический импорт.
|
||
|
||
Без SIGTERM `shutdown_requested()` всегда False → поведение идентично прежнему.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
# Module-level Event: создаётся на import'е. В Python 3.10+ asyncio.Event не
|
||
# привязывается к loop'у в __init__ (loop берётся лениво в .wait()), поэтому
|
||
# set()/is_set() безопасны даже до запуска event loop'а.
|
||
_shutdown_event = asyncio.Event()
|
||
|
||
|
||
def request_shutdown() -> None:
|
||
"""Запросить кооперативный drain. Идемпотентно — повторные вызовы безвредны."""
|
||
_shutdown_event.set()
|
||
|
||
|
||
def shutdown_requested() -> bool:
|
||
"""True после первого request_shutdown() (идёт кооперативный drain)."""
|
||
return _shutdown_event.is_set()
|
||
|
||
|
||
async def wait_for_shutdown() -> None:
|
||
"""Заблокироваться до request_shutdown(). Нужно safety-net drain'у в scheduler_main."""
|
||
await _shutdown_event.wait()
|
||
|
||
|
||
def reset_shutdown() -> None:
|
||
"""Сбросить флаг — ТОЛЬКО для тестов. Пересоздаёт Event (а не .clear()): asyncio.Event
|
||
кеширует event loop при первом .wait(), а pytest-asyncio даёт каждому тесту НОВЫЙ loop;
|
||
свежий объект избегает 'bound to a different event loop'. В проде не вызывается
|
||
(один asyncio.run → один loop), поэтому переприсвоение глобала здесь безопасно."""
|
||
global _shutdown_event
|
||
_shutdown_event = asyncio.Event()
|