gendesign/tradein-mvp/backend/app/core/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

48 lines
2.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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