Блок «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» (expected_sold, «с учётом торга») молча гас в «—» у части оценок с полноценным headline (прод: high-confidence, 17 аналогов, цена есть — expected_sold NULL). Причина: `_get_asking_sold_ratio` gracefully ловит ЛЮБУЮ ошибку БД → (None, None), но запись в `_asking_sold_ratio_cache` шла БЕЗУСЛОВНО (после try/except) → один транзиентный сбой (poisoned tx от вышестоящего graceful-except, коннект-хиккап) отравлял кэш бакета `(None, None)` на весь TTL (300с) и гасил expected_sold для ВСЕХ оценок этого rooms-бакета на воркере до истечения TTL. Фикс: ранний `return None, None` из except БЕЗ записи в кэш → следующая оценка ретраит. Кэшируем только успешный lookup (ratio может быть None, если строки реально нет — стабильный факт БД, безопасно кэшировать). Прод-диагностика (30 дней, 304 оценки): 39 пустых expected_sold, из них 37 — легитимно n_analogs=0 (нет аналогов), а 2 — этот баг (headline есть, ratio-кэш отравлён). Таблица asking_to_sold_ratios полна по всем бакетам + global fallback 0.843 → при исправном lookup ratio всегда резолвится. Юнит-тесты: транзиентная ошибка → None БЕЗ кэша; error→retry не залипает; успех кэшируется.
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""Regression: транзиентная ошибка НЕ должна отравлять asking→sold ratio-кэш.
|
||
|
||
Баг: `_get_asking_sold_ratio` gracefully ловит любую ошибку БД → (None, None), а
|
||
кэш писался БЕЗУСЛОВНО → один poisoned-tx / коннект-хиккап отравлял
|
||
`_asking_sold_ratio_cache[bucket] = (None, None)` на TTL (300с) и молча гасил
|
||
expected_sold («ожидаемая цена сделки» → «—») для ВСЕХ оценок этого rooms-бакета
|
||
на воркере до истечения TTL. Фикс: ранний return из except БЕЗ записи в кэш;
|
||
кэшируем только успешный lookup.
|
||
|
||
No DB: fake-Session (monkeypatched .execute/.rollback). DATABASE_URL нужен только
|
||
для импорта app.core.config.Settings (тот же паттерн, что в test_estimator_pure_units).
|
||
"""
|
||
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
from app.services import estimator
|
||
|
||
|
||
class _Result:
|
||
def __init__(self, row: Any) -> None:
|
||
self._row = row
|
||
|
||
def fetchone(self) -> Any:
|
||
return self._row
|
||
|
||
|
||
class _OkDB:
|
||
"""Fake Session: первый SELECT (per-rooms bucket) возвращает строку с ratio."""
|
||
|
||
def __init__(self, ratio: float, basis: str = "per_rooms") -> None:
|
||
self._row = SimpleNamespace(ratio=ratio, basis=basis)
|
||
self.rolled_back = False
|
||
|
||
def execute(self, *_a: Any, **_k: Any) -> _Result:
|
||
return _Result(self._row)
|
||
|
||
def rollback(self) -> None:
|
||
self.rolled_back = True
|
||
|
||
|
||
class _BoomDB:
|
||
"""Fake Session: execute всегда падает (poisoned tx / хиккап)."""
|
||
|
||
def __init__(self) -> None:
|
||
self.rolled_back = False
|
||
|
||
def execute(self, *_a: Any, **_k: Any) -> _Result:
|
||
raise RuntimeError("InFailedSqlTransaction")
|
||
|
||
def rollback(self) -> None:
|
||
self.rolled_back = True
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_ratio_cache() -> Any:
|
||
"""Чистим модульный кэш до/после каждого теста (глобальное состояние)."""
|
||
estimator._asking_sold_ratio_cache.clear()
|
||
yield
|
||
estimator._asking_sold_ratio_cache.clear()
|
||
|
||
|
||
def test_transient_error_returns_none_and_does_not_cache() -> None:
|
||
db = _BoomDB()
|
||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
||
assert (ratio, basis) == (None, None)
|
||
assert db.rolled_back is True
|
||
# bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
|
||
assert 2 not in estimator._asking_sold_ratio_cache
|
||
|
||
|
||
def test_transient_error_then_success_retries_not_poisoned() -> None:
|
||
# 1) транзиентный сбой — None, кэш не отравлен
|
||
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
|
||
assert r1 is None
|
||
assert 2 not in estimator._asking_sold_ratio_cache
|
||
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
|
||
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
|
||
assert r2 == pytest.approx(0.84)
|
||
assert basis2 == "per_rooms"
|
||
# теперь успешный результат закэширован
|
||
assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84)
|
||
|
||
|
||
def test_successful_lookup_is_cached() -> None:
|
||
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
|
||
assert r == pytest.approx(0.77)
|
||
assert basis == "per_rooms"
|
||
cached = estimator._asking_sold_ratio_cache.get(1)
|
||
assert cached is not None and cached[0] == pytest.approx(0.77)
|