gendesign/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py
bot-backend 3909db745b
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
fix(tradein/estimator): не отравлять asking→sold ratio-кэш транзиентной ошибкой
Блок «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» (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 не залипает;
успех кэшируется.
2026-07-02 20:34:10 +03:00

95 lines
3.9 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.

"""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)