"""Offline smoke for scheduler logic.""" import asyncio import os from unittest.mock import AsyncMock, MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime import pytest import app.services.scheduler as sched from app.core import shutdown as sd from app.services.scheduler import ZOMBIE_THRESHOLD_HOURS, compute_next_run_at @pytest.fixture(autouse=True) def _reset_drain_state() -> None: """#1182 P2: shutdown-флаг и registry детей — module-global; чистим вокруг каждого теста (изоляция от detached-задач, что триггеры спавнят в _inflight_tasks).""" sd.reset_shutdown() sched._inflight_tasks.clear() yield sched._inflight_tasks.clear() sd.reset_shutdown() def test_compute_next_run_in_normal_window() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at.date() == datetime(2026, 5, 24).date() assert 2 <= next_at.hour <= 4 # window [2, 5) UTC def test_compute_next_run_cross_midnight() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) # Window 22..3 = 22:00-23:59 + 00:00-02:59 next_at = compute_next_run_at(22, 3, now=now) # Должен попасть либо в [22,23] либо в [0,3) range hour = next_at.hour assert hour >= 22 or hour < 3 def test_compute_next_run_window_1_hour() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) # Минимальное окно [3, 4) → ровно 3-й час next_at = compute_next_run_at(3, 4, now=now) assert next_at.hour == 3 def test_zombie_threshold_constant() -> None: assert ZOMBIE_THRESHOLD_HOURS > 0 def test_compute_next_run_is_future() -> None: """next_run_at всегда в будущем (завтра) — не в прошлом.""" now = datetime(2026, 5, 23, 3, 30, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at > now def test_compute_next_run_timezone_aware() -> None: """Результат всегда timezone-aware (UTC).""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at.tzinfo is not None # ── per-source interval_days cadence (weekly exhaustive avito) ────────────── def test_compute_next_run_default_interval_is_daily() -> None: """Без interval_days → +1 сутки (back-compat, как раньше).""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(13, 15, now=now) assert next_at.date() == datetime(2026, 5, 24).date() assert 13 <= next_at.hour <= 14 # window [13, 15) UTC def test_compute_next_run_interval_days_1_explicit() -> None: """interval_days=1 явно → +1 сутки (идентично default).""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(13, 15, now=now, interval_days=1) assert next_at.date() == datetime(2026, 5, 24).date() assert 13 <= next_at.hour <= 14 def test_compute_next_run_interval_days_7_weekly() -> None: """interval_days=7 → ~7 суток вперёд, всё ещё в окне.""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(13, 15, now=now, interval_days=7) assert next_at.date() == datetime(2026, 5, 30).date() # 23 + 7 assert 13 <= next_at.hour <= 14 # всё ещё внутри окна [13, 15) UTC assert next_at > now def test_compute_next_run_interval_days_cross_midnight_weekly() -> None: """interval_days с cross-midnight окном → целевой день +N, час в окне.""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(22, 3, now=now, interval_days=7) assert next_at > now assert next_at.hour >= 22 or next_at.hour < 3 # ── #750: атомарный _claim_run (advisory lock — anti-double-sweep) ────────── from collections import deque # noqa: E402 from app.services import scheduler as _sched # noqa: E402 class _Scalar: def __init__(self, v: object) -> None: self._v = v def scalar(self) -> object: return self._v class _Fetchone: def __init__(self, row: object) -> None: self._row = row def fetchone(self) -> object: return self._row class _FakeSchedulerDB: """Stateful fake моделирующий advisory-lock claim (#750), без реальной БД. - SELECT pg_try_advisory_xact_lock → lock_available. - SELECT 1 FROM scrape_runs (has_running_run) → из running_results (если задано), иначе по флагу self.running[source]. - UPDATE scrape_schedules → no-op. create_run (монкипатчится) выставляет self.running[source]=True. """ def __init__(self, *, lock_available: bool = True, running_results: list[bool] | None = None): self.lock_available = lock_available self.running: dict[str, bool] = {} self._running_results: deque[bool] = deque(running_results or []) self.commits = 0 self.rollbacks = 0 def execute(self, stmt: object, params: dict | None = None): sql = str(stmt) if "pg_try_advisory_xact_lock" in sql: return _Scalar(self.lock_available) if "SELECT 1 FROM scrape_runs" in sql: if self._running_results: return _Fetchone(object() if self._running_results.popleft() else None) src = (params or {}).get("source") return _Fetchone(object() if self.running.get(src) else None) return _Fetchone(None) # UPDATE scrape_schedules etc. def commit(self) -> None: self.commits += 1 def rollback(self) -> None: self.rollbacks += 1 _SCHED_ROW = { "source": "avito_city_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": {}, } def test_claim_run_double_call_creates_one_run(monkeypatch: pytest.MonkeyPatch) -> None: """#750 DoD: два последовательных _claim_run одного source (нет running) → первый run_id, второй None (create_run вызван РОВНО один раз).""" db = _FakeSchedulerDB() calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 db.running[source] = True return 100 + calls["n"] monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) rid1 = _sched._claim_run(db, _SCHED_ROW) rid2 = _sched._claim_run(db, _SCHED_ROW) assert rid1 == 101 assert rid2 is None assert calls["n"] == 1 # второй НЕ создал run def test_claim_run_skips_when_advisory_lock_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: """#750: lock занят конкурентным тиком → return None, create_run НЕ вызван.""" db = _FakeSchedulerDB(lock_available=False) calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) assert _sched._claim_run(db, _SCHED_ROW) is None assert calls["n"] == 0 def test_claim_run_rechecks_running_under_lock(monkeypatch: pytest.MonkeyPatch) -> None: """#750: running появился МЕЖДУ pre-check и взятием лока (double-checked) → return None + rollback (освобождение лока), create_run НЕ вызван.""" # pre-check → False (нет running), re-check под локом → True (появился). db = _FakeSchedulerDB(lock_available=True, running_results=[False, True]) calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) assert _sched._claim_run(db, _SCHED_ROW) is None assert calls["n"] == 0 assert db.rollbacks == 1 # лок освобождён # ── #974: trigger_yandex_newbuilding_sweep_run + dispatch ──────────────────── _YANDEX_NB_SWEEP_ROW = { "source": "yandex_newbuilding_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": { "limit": 5, "request_delay_sec": 8.0, "city": "ekaterinburg", }, } @pytest.mark.asyncio async def test_trigger_yandex_newbuilding_sweep_creates_run( monkeypatch: pytest.MonkeyPatch, ) -> None: """trigger_yandex_newbuilding_sweep_run claims run + launches create_task (#974).""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "sweep": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 301 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) # Patch enrich_yandex_newbuilding_sweep to a coroutine that records call sweep_result = MagicMock() sweep_result.to_dict.return_value = {} async def fake_sweep(run_db, *, limit, **kwargs): calls["sweep"].append(limit) return sweep_result # Patch inside the _run closure (lazy import) monkeypatch.setattr( "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", fake_sweep, ) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) # mark_done is called inside _run; mock it so it doesn't fail on fake db monkeypatch.setattr(_sched.runs_mod, "mark_done", lambda *a, **kw: None) monkeypatch.setattr(_sched.runs_mod, "mark_failed", lambda *a, **kw: None) run_id = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) assert run_id == 301 assert calls["create_run"] == ["yandex_newbuilding_sweep"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert calls["sweep"] == [5] @pytest.mark.asyncio async def test_trigger_yandex_newbuilding_sweep_skips_when_running( monkeypatch: pytest.MonkeyPatch, ) -> None: """trigger_yandex_newbuilding_sweep_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["yandex_newbuilding_sweep"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_yandex_newbuilding_sweep( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dispatch block в scheduler_loop роутит yandex_newbuilding_sweep → trigger.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_yandex_newbuilding_sweep_run", fake_trigger) db = object() sch = _YANDEX_NB_SWEEP_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for yandex_newbuilding_sweep if source == "yandex_newbuilding_sweep": await _sched.trigger_yandex_newbuilding_sweep_run(db, sch) assert "yandex_newbuilding_sweep" in triggered # ── #973: trigger_cian_city_sweep_run + dispatch ───────────────────────────── _CIAN_SWEEP_ROW = { "source": "cian_city_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": { "pages_per_anchor": 3, "request_delay_sec": 5.0, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": True, }, } @pytest.mark.asyncio async def test_trigger_cian_city_sweep_creates_run(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_city_sweep_run claims run + launches create_task (#973).""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "sweep": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 201 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) # Patch run_cian_city_sweep to a coroutine that records call async def fake_sweep(run_db, *, run_id, **kwargs): calls["sweep"].append(run_id) monkeypatch.setattr(_sched, "run_cian_city_sweep", fake_sweep) # Patch SessionLocal to return the same db monkeypatch.setattr(_sched, "SessionLocal", lambda: db) run_id = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) assert run_id == 201 assert calls["create_run"] == ["cian_city_sweep"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert calls["sweep"] == [201] @pytest.mark.asyncio async def test_trigger_cian_city_sweep_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_city_sweep_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["cian_city_sweep"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_cian_city_sweep(monkeypatch: pytest.MonkeyPatch) -> None: """Dispatch block в scheduler_loop роутит cian_city_sweep → trigger_cian_city_sweep_run.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_cian_city_sweep_run", fake_trigger) # Simulate the dispatch block for one due schedule db = object() sch = _CIAN_SWEEP_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for cian_city_sweep if source == "cian_city_sweep": await _sched.trigger_cian_city_sweep_run(db, sch) assert "cian_city_sweep" in triggered # ── cian_full_load: trigger_cian_full_load_run + dispatch ──────────────────── _CIAN_FULL_LOAD_ROW = { "source": "cian_full_load", "window_start_hour": 20, "window_end_hour": 22, "default_params": { "price_cap_per_bucket": 1400, "concurrency": 5, "request_delay_sec": 4.0, "enrich_detail": False, "detail_top_n": 0, }, } @pytest.mark.asyncio async def test_trigger_cian_full_load_creates_run(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_full_load_run claims run + launches run_cian_full_load via create_task.""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "load": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 301 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) async def fake_full_load(run_db, *, run_id, **kwargs): calls["load"].append((run_id, kwargs)) monkeypatch.setattr(_sched, "run_cian_full_load", fake_full_load) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) run_id = await _sched.trigger_cian_full_load_run(db, _CIAN_FULL_LOAD_ROW) assert run_id == 301 assert calls["create_run"] == ["cian_full_load"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert len(calls["load"]) == 1 loaded_run_id, kwargs = calls["load"][0] assert loaded_run_id == 301 # Params propagated from default_params; resume_run_id forced to None assert kwargs["price_cap_per_bucket"] == 1400 assert kwargs["concurrency"] == 5 assert kwargs["request_delay_sec"] == 4.0 assert kwargs["enrich_detail"] is False assert kwargs["detail_top_n"] == 0 assert kwargs["resume_run_id"] is None @pytest.mark.asyncio async def test_trigger_cian_full_load_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_full_load_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["cian_full_load"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_cian_full_load_run(db, _CIAN_FULL_LOAD_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_cian_full_load(monkeypatch: pytest.MonkeyPatch) -> None: """Dispatch block в scheduler_loop роутит cian_full_load → trigger_cian_full_load_run.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_cian_full_load_run", fake_trigger) db = object() sch = _CIAN_FULL_LOAD_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for cian_full_load if source == "cian_full_load": await _sched.trigger_cian_full_load_run(db, sch) assert "cian_full_load" in triggered # ── avito_full_load: trigger_avito_full_load_run + dispatch ────────────────── _AVITO_FULL_LOAD_ROW = { "source": "avito_full_load", "window_start_hour": 13, "window_end_hour": 15, "default_params": { "price_cap_per_bucket": 1400, "concurrency": 5, "request_delay_sec": 7.0, "secondary_only": True, }, } @pytest.mark.asyncio async def test_trigger_avito_full_load_creates_run(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_avito_full_load_run claims run + launches run_avito_full_load via create_task.""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "load": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 401 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) async def fake_full_load(run_db, *, run_id, **kwargs): calls["load"].append((run_id, kwargs)) monkeypatch.setattr(_sched, "run_avito_full_load", fake_full_load) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) run_id = await _sched.trigger_avito_full_load_run(db, _AVITO_FULL_LOAD_ROW) assert run_id == 401 assert calls["create_run"] == ["avito_full_load"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert len(calls["load"]) == 1 loaded_run_id, kwargs = calls["load"][0] assert loaded_run_id == 401 # Params propagated from default_params; resume_run_id forced to None assert kwargs["price_cap_per_bucket"] == 1400 assert kwargs["concurrency"] == 5 assert kwargs["request_delay_sec"] == 7.0 assert kwargs["secondary_only"] is True assert kwargs["resume_run_id"] is None @pytest.mark.asyncio async def test_trigger_avito_full_load_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_avito_full_load_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["avito_full_load"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_avito_full_load_run(db, _AVITO_FULL_LOAD_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_avito_full_load(monkeypatch: pytest.MonkeyPatch) -> None: """Dispatch block в scheduler_loop роутит avito_full_load → trigger_avito_full_load_run.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_avito_full_load_run", fake_trigger) db = object() sch = _AVITO_FULL_LOAD_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for avito_full_load if source == "avito_city_sweep": pass elif source == "avito_full_load": await _sched.trigger_avito_full_load_run(db, sch) assert "avito_full_load" in triggered # ── avito_full_load incremental_days + avito_full_load_exhaustive split ─────── @pytest.mark.asyncio async def test_trigger_avito_full_load_reads_incremental_days( monkeypatch: pytest.MonkeyPatch, ) -> None: """incremental_days из default_params прокидывается в run_avito_full_load.""" db = _FakeSchedulerDB() calls: dict[str, list] = {"load": []} def fake_create_run(_d, *, source, params): db.running[source] = True return 410 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) async def fake_full_load(run_db, *, run_id, **kwargs): calls["load"].append(kwargs) monkeypatch.setattr(_sched, "run_avito_full_load", fake_full_load) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) row = { **_AVITO_FULL_LOAD_ROW, "default_params": {**_AVITO_FULL_LOAD_ROW["default_params"], "incremental_days": 2}, } await _sched.trigger_avito_full_load_run(db, row) await asyncio.sleep(0) assert len(calls["load"]) == 1 assert calls["load"][0]["incremental_days"] == 2 @pytest.mark.asyncio async def test_trigger_avito_full_load_no_incremental_days_is_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """Без incremental_days в params → run_avito_full_load(incremental_days=None) (exhaustive).""" db = _FakeSchedulerDB() calls: dict[str, list] = {"load": []} def fake_create_run(_d, *, source, params): db.running[source] = True return 411 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) async def fake_full_load(run_db, *, run_id, **kwargs): calls["load"].append(kwargs) monkeypatch.setattr(_sched, "run_avito_full_load", fake_full_load) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) await _sched.trigger_avito_full_load_run(db, _AVITO_FULL_LOAD_ROW) await asyncio.sleep(0) assert len(calls["load"]) == 1 assert calls["load"][0]["incremental_days"] is None _AVITO_FULL_LOAD_EXHAUSTIVE_ROW = { "source": "avito_full_load_exhaustive", "window_start_hour": 13, "window_end_hour": 15, "default_params": { "price_cap_per_bucket": 1400, "concurrency": 1, "request_delay_sec": 7.0, "secondary_only": True, }, } @pytest.mark.asyncio async def test_trigger_avito_full_load_exhaustive_forces_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """avito_full_load_exhaustive форсит incremental_days=None даже если в params задан.""" db = _FakeSchedulerDB() calls: dict[str, list] = {"load": []} def fake_create_run(_d, *, source, params): db.running[source] = True return 420 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) async def fake_full_load(run_db, *, run_id, **kwargs): calls["load"].append((run_id, kwargs)) monkeypatch.setattr(_sched, "run_avito_full_load", fake_full_load) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) # Даже если оператор случайно положит incremental_days в params — exhaustive игнорит. row = { **_AVITO_FULL_LOAD_EXHAUSTIVE_ROW, "default_params": { **_AVITO_FULL_LOAD_EXHAUSTIVE_ROW["default_params"], "incremental_days": 5, }, } run_id = await _sched.trigger_avito_full_load_exhaustive_run(db, row) await asyncio.sleep(0) assert run_id == 420 assert len(calls["load"]) == 1 loaded_run_id, kwargs = calls["load"][0] assert loaded_run_id == 420 assert kwargs["incremental_days"] is None assert kwargs["concurrency"] == 1 assert kwargs["price_cap_per_bucket"] == 1400 assert kwargs["resume_run_id"] is None @pytest.mark.asyncio async def test_scheduler_dispatch_routes_avito_full_load_exhaustive( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dispatch роутит avito_full_load_exhaustive → trigger_avito_full_load_exhaustive_run.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_avito_full_load_exhaustive_run", fake_trigger) db = object() sch = _AVITO_FULL_LOAD_EXHAUSTIVE_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop if source == "avito_full_load": pass elif source == "avito_full_load_exhaustive": await _sched.trigger_avito_full_load_exhaustive_run(db, sch) assert "avito_full_load_exhaustive" in triggered # --------------------------------------------------------------------------- # #1182 Phase 2 — graceful SIGTERM-drain детей (detached run-задач) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_drain_inflight_awaits_detached_cooperative_child() -> None: """Гвоздь Phase 2: _drain_inflight ДОЖИДАЕТСЯ detached-ребёнка (как scrape-run, отвязанный через _spawn_tracked) до его clean-exit, а НЕ хард-кансельит его. Регрессия gap'а: coordinator раньше ждал только себя → asyncio.run teardown хард-кансельил живых детей mid-await (#1182 failure mode). """ clean_exit = asyncio.Event() # == mark_done-эквивалент ребёнка async def _child() -> None: # Кооперативный ребёнок: крутится, пока не запрошен shutdown, потом выходит чисто. while not sd.shutdown_requested(): await asyncio.sleep(0.01) clean_exit.set() child = sched._spawn_tracked(_child()) # detached, попадает в _inflight_tasks await asyncio.sleep(0.02) assert not child.done(), "ребёнок ещё бежит (shutdown не запрошен)" sd.request_shutdown() await asyncio.wait_for(sched._drain_inflight(), timeout=2.0) assert clean_exit.is_set(), "drain дождался clean-exit ребёнка" assert child.done() assert not child.cancelled(), "ребёнок вышел сам, НЕ хард-cancel" @pytest.mark.asyncio async def test_drain_inflight_times_out_on_noncooperative_child( monkeypatch: pytest.MonkeyPatch, ) -> None: """Некооперативный ребёнок (игнорит shutdown) упирается в _CHILD_DRAIN_TIMEOUT_S и остаётся бежать — отдаётся внешнему hard-cancel fallback (scheduler_main/docker).""" monkeypatch.setattr(sched, "_CHILD_DRAIN_TIMEOUT_S", 0.05) async def _stuck() -> None: await asyncio.Event().wait() # никогда не проверяет shutdown_requested() child = sched._spawn_tracked(_stuck()) sd.request_shutdown() await asyncio.wait_for(sched._drain_inflight(), timeout=2.0) assert not child.done(), "drain НЕ повесил процесс — оставил ребёнка под hard-cancel" # cleanup child.cancel() with pytest.raises(asyncio.CancelledError): await child @pytest.mark.asyncio async def test_drain_inflight_noop_when_no_children() -> None: """Без in-flight детей _drain_inflight() — мгновенный no-op (без сна/ожидания).""" assert not sched._inflight_tasks await asyncio.wait_for(sched._drain_inflight(), timeout=1.0) @pytest.mark.asyncio async def test_scheduler_loop_calls_drain_on_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: """scheduler_loop при выходе по SIGTERM-drain'у вызывает _drain_inflight (проводка coordinator → дренаж детей). Initial sleep(30) и tick-sleep занулены, чтобы тест был детерминированным и быстрым.""" drain_spy = AsyncMock() monkeypatch.setattr(sched, "_drain_inflight", drain_spy) monkeypatch.setattr(sched.asyncio, "sleep", AsyncMock()) # без реальных 30s/60s sd.request_shutdown() # первый же top-of-tick check → break await asyncio.wait_for(sched.scheduler_loop(), timeout=2.0) drain_spy.assert_awaited_once()