From 3909db745bad988615f51c5d7c0a3af3b68f6cc5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 20:34:10 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20asking?= =?UTF-8?q?=E2=86=92sold=20ratio-=D0=BA=D1=8D=D1=88=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B7=D0=B8=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B9=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» (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 не залипает; успех кэшируется. --- tradein-mvp/backend/app/services/estimator.py | 14 ++- .../test_asking_sold_ratio_cache_poison.py | 95 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c176d71d..79b8c44e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -282,13 +282,23 @@ def _get_asking_sold_ratio( # либо транзакция в сбойном состоянии — graceful: без sold-коррекции. # ОБЯЗАТЕЛЬНО rollback: неудачный SELECT помечает транзакцию # InFailedSqlTransaction, и без отката следующий statement упал бы → 500. - logger.debug("asking_to_sold_ratio lookup skipped (graceful): %s", exc) + # + # НЕ кэшируем этот None: ошибка транзиентна (poisoned tx от вышестоящего + # graceful-except, миг. лаг, коннект-хиккап). Раньше строка кэша ниже + # писалась безусловно → один сбой отравлял _asking_sold_ratio_cache[bucket] + # = (None, None) на весь TTL (300с) и молча гасил expected_sold («ожидаемая + # цена сделки» → «—») для ВСЕХ оценок этого rooms-бакета на воркере до + # истечения TTL. Ранний return без записи в кэш → следующая оценка ретраит. + logger.debug("asking_to_sold_ratio lookup skipped (graceful, NOT cached): %s", exc) try: db.rollback() except Exception: pass - ratio, basis = None, None + return None, None + # Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет — + # стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше + # уже вернулся ранним return и сюда не доходит. _asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic()) return ratio, basis diff --git a/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py new file mode 100644 index 00000000..b717d562 --- /dev/null +++ b/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py @@ -0,0 +1,95 @@ +"""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) -- 2.45.3