"""Кооперативный 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()