gendesign/tradein-mvp/backend/tests/test_estimate_external_sources_background.py
bot-backend 5b447ec33d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
feat(tradein/estimate): внешние оценки не ждутся в запросе — 9 секунд превращаются в 1
Замер на проде 2026-08-22, разбивка одного расчёта по логам:

    10.659  старт
    10.827  дом найден              0.17 с
    11.565  аналоги, 49 кандидатов  0.74 с
    11.579  ДКП-коридор             0.01 с
    17.576  yandex_valuation        ← 6.0 с
    19.159  cian_valuation          ← 1.5 с
    19.252  готово                  итого 8.6 с

Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.

Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.

Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.

Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.

Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.

Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.

Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.

Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.

Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
2026-08-22 15:51:31 +03:00

212 lines
8.3 KiB
Python
Raw Permalink 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.

"""Внешние источники оценки не ждутся в запросе, а догружаются в фоне.
Замер на проде 2026-08-22: расчёт по новому адресу 8-12 с, из них ~6 с ждёт
Yandex и ~1.5 с Cian; собственный расчёт укладывается в секунду. По уже
виденному адресу (кэш 24 ч) — 0.4-0.8 с.
Ключевое свойство, которое делает переключение безопасным: деградация источника
в None — НЕ новое состояние ответа. Ровно так же ведёт себя таймаут
`estimate_*_valuation_timeout_s`, и этот путь работает в проде сегодня.
Тесты проверяют три вещи, каждая из которых при поломке молча вернула бы
секунды ожидания в пользовательский путь:
1. `fetch_on_miss=False` действительно НЕ ходит в сеть при промахе кэша;
2. попадание в кэш работает одинаково в обоих режимах;
3. отложенная задача берёт СВОЮ сессию и гасит свои ошибки.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
import pytest
class _NoRowsDB:
"""Сессия, у которой кэш всегда пуст."""
def __init__(self) -> None:
self.commits = 0
self.closed = False
def execute(self, *_a: Any, **_kw: Any) -> Any:
result = MagicMock()
result.mappings.return_value.first.return_value = None
return result
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
return None
def close(self) -> None:
self.closed = True
# ── Yandex ───────────────────────────────────────────────────────────────────
def test_yandex_cache_only_does_not_touch_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_on_miss=False → None и НИ ОДНОГО обращения к скраперу."""
from app.services import estimator
def _boom(*_a: Any, **_kw: Any) -> Any:
raise AssertionError("сеть не должна дёргаться в режиме «только кэш»")
monkeypatch.setattr(estimator, "YandexValuationScraper", _boom)
out = asyncio.run(
estimator._get_or_fetch_yandex_valuation_cached(
_NoRowsDB(), # type: ignore[arg-type]
address="Екатеринбург, улица Сурикова, 4",
fetch_on_miss=False,
)
)
assert out is None
def test_yandex_default_still_fetches(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолт не изменился: без флага источник по-прежнему грузится в запросе."""
from app.services import estimator
called: dict[str, bool] = {}
class _Scraper:
def __init__(self, *_a: Any, **_kw: Any) -> None:
called["constructed"] = True
async def __aenter__(self) -> _Scraper:
return self
async def __aexit__(self, *_a: Any) -> None:
return None
async def fetch_house_history(self, **_kw: Any) -> None:
return None
monkeypatch.setattr(estimator, "YandexValuationScraper", _Scraper)
asyncio.run(
estimator._get_or_fetch_yandex_valuation_cached(
_NoRowsDB(), # type: ignore[arg-type]
address="Екатеринбург, улица Сурикова, 4",
)
)
assert called.get("constructed") is True
# ── отложенная догрузка ──────────────────────────────────────────────────────
def test_deferred_refresh_uses_its_own_session(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сессия запроса закрывается вместе с ответом — фон обязан открыть свою.
Переиспользование чужой сессии зафиксировало бы её незавершённую работу:
обе функции источников делают внутри себя `db.commit()`.
"""
from app.services import estimator
own = _NoRowsDB()
request_db = _NoRowsDB()
monkeypatch.setattr(estimator, "SessionLocal", lambda: own)
seen: dict[str, Any] = {}
async def _work(db: Any) -> None:
seen["db"] = db
async def _drive() -> None:
estimator._defer_external_refresh("test", _work)
await asyncio.sleep(0)
await asyncio.sleep(0)
asyncio.run(_drive())
assert seen["db"] is own, "фоновая задача взяла не свою сессию"
assert seen["db"] is not request_db
assert own.closed is True, "фоновая сессия осталась незакрытой"
def test_deferred_refresh_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None:
"""Провал прогрева кэша не должен всплывать: это не часть ответа."""
from app.services import estimator
own = _NoRowsDB()
monkeypatch.setattr(estimator, "SessionLocal", lambda: own)
async def _work(_db: Any) -> None:
raise RuntimeError("внешний сервис лёг")
async def _drive() -> None:
estimator._defer_external_refresh("test", _work)
await asyncio.sleep(0)
await asyncio.sleep(0)
asyncio.run(_drive()) # не должно поднять
assert own.closed is True
def test_deferred_refresh_without_event_loop_is_noop() -> None:
"""Синхронный контекст (скрипт/тест) — прогрев необязателен, не падаем."""
from app.services import estimator
async def _work(_db: Any) -> None:
raise AssertionError("не должно вызваться")
estimator._defer_external_refresh("test", _work) # без запущенного loop
def test_task_reference_is_retained(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ссылка на задачу удерживается: иначе GC может убить её на полпути."""
from app.services import estimator
monkeypatch.setattr(estimator, "SessionLocal", _NoRowsDB)
started = asyncio.Event()
release = asyncio.Event()
async def _work(_db: Any) -> None:
started.set()
await release.wait()
async def _drive() -> None:
estimator._defer_external_refresh("test", _work)
await started.wait()
assert len(estimator._DEFERRED_REFRESH_TASKS) == 1
release.set()
await asyncio.sleep(0)
await asyncio.sleep(0)
assert len(estimator._DEFERRED_REFRESH_TASKS) == 0, "ссылка не убрана после завершения"
asyncio.run(_drive())
def test_deferred_queue_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
"""Всплеск по новым адресам не должен породить сотни задач.
Ровно тот случай, ради которого режим и сделан: у backend один воркер,
mem_limit 768m, у Postgres max_connections 100. Неограниченный create_task
превратил бы ускорение в отказ.
"""
from app.services import estimator
monkeypatch.setattr(estimator, "SessionLocal", _NoRowsDB)
monkeypatch.setattr(estimator, "_MAX_DEFERRED_REFRESH_TASKS", 3)
release = asyncio.Event()
async def _work(_db: Any) -> None:
await release.wait()
async def _drive() -> None:
for _ in range(50):
estimator._defer_external_refresh("test", _work)
await asyncio.sleep(0)
assert len(estimator._DEFERRED_REFRESH_TASKS) <= 3, (
f"очередь догрузки не ограничена: {len(estimator._DEFERRED_REFRESH_TASKS)} задач"
)
release.set()
for _ in range(5):
await asyncio.sleep(0)
asyncio.run(_drive())