Merge pull request 'fix(tradein/estimator): не отравлять asking→sold ratio-кэш транзиентной ошибкой' (#2175) from fix/tradein-asking-sold-ratio-cache-poison into main
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
This commit is contained in:
commit
94114adca1
2 changed files with 107 additions and 2 deletions
|
|
@ -282,13 +282,23 @@ def _get_asking_sold_ratio(
|
||||||
# либо транзакция в сбойном состоянии — graceful: без sold-коррекции.
|
# либо транзакция в сбойном состоянии — graceful: без sold-коррекции.
|
||||||
# ОБЯЗАТЕЛЬНО rollback: неудачный SELECT помечает транзакцию
|
# ОБЯЗАТЕЛЬНО rollback: неудачный SELECT помечает транзакцию
|
||||||
# InFailedSqlTransaction, и без отката следующий statement упал бы → 500.
|
# 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:
|
try:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ratio, basis = None, None
|
return None, None
|
||||||
|
|
||||||
|
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
|
||||||
|
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
|
||||||
|
# уже вернулся ранним return и сюда не доходит.
|
||||||
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
|
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
|
||||||
return ratio, basis
|
return ratio, basis
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue