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
Замер на проде 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 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
212 lines
8.3 KiB
Python
212 lines
8.3 KiB
Python
"""Внешние источники оценки не ждутся в запросе, а догружаются в фоне.
|
||
|
||
Замер на проде 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())
|