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
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""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 # лок освобождён
|