gendesign/tradein-mvp/backend/tests/test_scheduler.py
bot-backend 2226b16cd3
All checks were successful
Deploy Trade-In / deploy (push) Successful in 34s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / build-backend (push) Successful in 42s
fix(scheduler): atomic _claim_run via advisory xact lock (Refs #750) (#752)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 16:04:33 +00:00

172 lines
5.8 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.

"""Offline smoke for scheduler logic."""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
import pytest
from app.services.scheduler import ZOMBIE_THRESHOLD_HOURS, compute_next_run_at
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
# ── #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 # лок освобождён