All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m32s
Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same horizon/segment-invariant DB loads with identical args hundreds of times per report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each unique load runs ONCE and reuses the same frozen, read-only instance. Output is byte-identical (memoized producers are frozen dataclasses / read-only Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op outside the report build). No concurrency, no signature changes. - forecast_request_cache.py: ContextVar cache + cached() decorator (no-op outside context, reentrant, _MISS sentinel for cached None) - @cached on competitors/future_supply/market_metrics/macro_series/ sales_series/macro_coefficient/demand_normalization/regression loaders - orchestrator: wrap build_site_finder_report in forecast_cache() - 58 tests: key discrimination (call-counting regression guard), no-op-outside, per-report isolation, reentrancy, frozen-producer canary, amplification proof (real get_monthly_macro xN->1) code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265 forecast/cache tests green. No new deps. Refs #1129.
593 lines
34 KiB
Python
593 lines
34 KiB
Python
"""Unit-тесты per-request memoization-кэша §22-форсайта (#1129).
|
||
|
||
Кэш (`app/services/forecast_request_cache.py`) НЕ меняет сигнатуры §9.x-сервисов:
|
||
`forecast_cache()` (ContextVar-контекст) активируется один раз в оркестраторе
|
||
(`build_site_finder_report`), а `@cached(key_builder)`-декоратор мемоизирует дорогие
|
||
горизонт/сегмент-инвариантные БД-загрузки по ЯВНОМУ ключу. Внутри контекста —
|
||
результат считается один раз на уникальный ключ и переиспользуется (ТОТ ЖЕ
|
||
frozen-инстанс); вне контекста — no-op (обычный вызов без кэша).
|
||
|
||
Что пинят тесты (БЕЗ живой БД — call-counting спаи / прямые проверки key-builder'ов):
|
||
|
||
1. ДИСКРИМИНАЦИЯ КЛЮЧЕЙ (главный регресс-гейт): одинаковые аргументы в одном
|
||
`forecast_cache()` → подлежащая функция вызвана ОДИН раз (2-й — из кэша); другой
|
||
влияющий на результат аргумент → вызвана ДВАЖДЫ (нет коллизии ключей). Опасные
|
||
аргументы покрыты явно через РЕАЛЬНЫЕ key-builder'ы загрузок: get_competitors по
|
||
horizon_months И radius_km; compute_future_supply_pressure по horizon_months;
|
||
compute_demand_normalization по rate_future; compute_market_metrics по window_months.
|
||
2. NO-OP ВНЕ КОНТЕКСТА: `@cached`-функция дважды без активного `forecast_cache()` →
|
||
подлежащая вызвана дважды; `is_active()` False снаружи, True внутри.
|
||
3. ИЗОЛЯЦИЯ PER-ОТЧЁТ: два ПОСЛЕДОВАТЕЛЬНЫХ `with forecast_cache():` → 2-й пере-
|
||
считывает (не видит записей 1-го). Реентерабельность: вложенный `forecast_cache()`
|
||
переиспользует активный кэш (подлежащая вызвана один раз на весь вложенный блок).
|
||
4. КАНАРЕЙКА НЕИЗМЕНЯЕМОСТИ: все мемоизируемые продьюсеры возвращают frozen-dataclass —
|
||
если кто-то в будущем сделает их mutable, шаринг инстанса станет небезопасен, и
|
||
этот тест упадёт (явный сигнал пересмотреть кэш).
|
||
|
||
Плюс AMPLIFICATION-PROOF (без тяжёлой БД-фикстуры): на реальной загрузке
|
||
`get_monthly_macro` (БД-хелперы запатчены спаями) повторный паттерн вызовов внутри
|
||
одного отчёта схлопывается в ОДИН реальный roundtrip — демонстрация ×N→1 редукции,
|
||
ради которой кэш и сделан (профиль: get_monthly_macro ×290 на отчёт).
|
||
|
||
Детерминированно, без сети/БД (monkeypatch). asyncio_mode=auto (тесты sync — кэш sync).
|
||
DATABASE_URL до импорта app-модулей (зеркало соседних forecast-тестов — на случай
|
||
side-effect'ов импорта пакетов forecasting/site_finder при сборе).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import os
|
||
from collections.abc import Iterator
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.schemas.parcel import CompetitorsRequest
|
||
from app.services.forecast_request_cache import (
|
||
cached,
|
||
forecast_cache,
|
||
is_active,
|
||
)
|
||
|
||
# Реальные key-builder'ы загрузок — тестируем ПРОДАКШН-ключи, а не их копии.
|
||
from app.services.forecasting.demand_normalization import (
|
||
DemandNormalization,
|
||
compute_demand_normalization,
|
||
)
|
||
from app.services.forecasting.macro_coefficient import (
|
||
MacroCoefficient,
|
||
compute_macro_coefficient,
|
||
)
|
||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
||
from app.services.forecasting.rate_sensitivity import RateSensitivity
|
||
from app.services.forecasting.regression import compute_rate_regime_sensitivity
|
||
from app.services.forecasting.sales_series import (
|
||
SalesSeries,
|
||
SegmentSpec,
|
||
_sales_series_key,
|
||
build_sales_series,
|
||
)
|
||
from app.services.site_finder.competitors import _competitors_key, get_competitors
|
||
from app.services.site_finder.future_supply import (
|
||
FutureSupplyPressure,
|
||
_future_supply_key,
|
||
compute_future_supply_pressure,
|
||
)
|
||
from app.services.site_finder.market_metrics import (
|
||
MarketMetrics,
|
||
_market_metrics_key,
|
||
compute_market_metrics,
|
||
)
|
||
|
||
# db-сессия в ключ кэша НЕ входит → sentinel. MagicMock (а не object()) — соседняя
|
||
# конвенция тестов: mypy видит его как Any, поэтому подходит под параметр `Session`,
|
||
# и реальные загрузки (get_monthly_macro) с запатченными БД-хелперами его не трогают.
|
||
_DB: Any = MagicMock(name="db_sentinel")
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Хелперы: счётчик-спай, обёрнутый РЕАЛЬНЫМ key-builder'ом через @cached.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class _Counter:
|
||
"""Счётчик реальных вызовов подлежащей функции (через @cached-обёртку)."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls = 0
|
||
|
||
def bump(self) -> int:
|
||
self.calls += 1
|
||
return self.calls
|
||
|
||
|
||
def _spy_with_key(key_builder: Any, *, label: str) -> tuple[Any, _Counter]:
|
||
"""`@cached(key_builder)`-обёрнутая фейк-функция, считающая РЕАЛЬНЫЕ вызовы тела.
|
||
|
||
Возвращает (обёрнутая_функция, счётчик). Декорируем РЕАЛЬНЫМ key-builder'ом
|
||
загрузки (тот же объект, что навешен на продакшн-функцию) — так тест ловит и
|
||
верность ключа, и поведение кэша одновременно, без живой БД.
|
||
"""
|
||
counter = _Counter()
|
||
|
||
@cached(key_builder, label=label)
|
||
def _fn(*args: Any, **kwargs: Any) -> object:
|
||
counter.bump()
|
||
# Возвращаем НОВЫЙ объект на каждый реальный вызов → identity-проверка «тот же
|
||
# инстанс» однозначно отличает hit (тот же объект) от miss (новый объект).
|
||
return object()
|
||
|
||
return _fn, counter
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 1. Дискриминация ключей — на ПРИМИТИВЕ кэша напрямую (главный регресс-гейт).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCachePrimitiveKeyDiscrimination:
|
||
def test_same_key_calls_underlying_once(self) -> None:
|
||
"""Одинаковый ключ в одном контексте → тело вызвано ОДИН раз, 2-й из кэша."""
|
||
fn, counter = _spy_with_key(lambda x: x, label="fake")
|
||
with forecast_cache():
|
||
r1 = fn(7)
|
||
r2 = fn(7)
|
||
assert counter.calls == 1
|
||
assert r1 is r2 # тот же инстанс — шаринг кэша
|
||
|
||
def test_different_key_calls_underlying_twice(self) -> None:
|
||
"""Разный ключ → тело вызвано ДВАЖДЫ (нет коллизии ключей)."""
|
||
fn, counter = _spy_with_key(lambda x: x, label="fake")
|
||
with forecast_cache():
|
||
r1 = fn(7)
|
||
r2 = fn(8)
|
||
assert counter.calls == 2
|
||
assert r1 is not r2
|
||
|
||
def test_cached_none_is_a_hit_not_a_miss(self) -> None:
|
||
"""Закэшированный None — это HIT (сентинел _MISS отличает его от промаха)."""
|
||
counter = _Counter()
|
||
|
||
@cached(lambda x: x, label="returns_none")
|
||
def _fn(x: int) -> None:
|
||
counter.bump()
|
||
return None
|
||
|
||
with forecast_cache():
|
||
assert _fn(1) is None
|
||
assert _fn(1) is None
|
||
assert counter.calls == 1 # 2-й вызов обслужен из кэша, а не пересчитан
|
||
|
||
def test_label_namespaces_keys(self) -> None:
|
||
"""Разный label при ОДИНАКОВОМ ключе аргументов → разные слоты (нет коллизии)."""
|
||
fn_a, counter_a = _spy_with_key(lambda x: x, label="label_a")
|
||
fn_b, counter_b = _spy_with_key(lambda x: x, label="label_b")
|
||
with forecast_cache():
|
||
fn_a(1)
|
||
fn_b(1) # тот же аргумент-ключ, но другой label → отдельный слот
|
||
assert counter_a.calls == 1
|
||
assert counter_b.calls == 1
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 1b. Дискриминация ключей на РЕАЛЬНЫХ key-builder'ах загрузок (call-counting через
|
||
# @cached) — покрываем «опасные» горизонт/окно/ставка/радиус-аргументы явно.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRealLoaderKeyDiscrimination:
|
||
"""Каждый кейс декорирует спай РЕАЛЬНЫМ key-builder'ом загрузки и считает вызовы.
|
||
|
||
Так регресс «забыли горизонт/окно/ставку/радиус в ключе» → схлопывание в 1 вызов,
|
||
где ожидается 2 → тест падает. db-сессия в ключ не входит → sentinel _DB.
|
||
"""
|
||
|
||
# ── get_competitors: horizon_months И radius_km обязаны различать ──────────
|
||
|
||
def test_competitors_same_request_once(self) -> None:
|
||
fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
|
||
req = CompetitorsRequest(radius_km=1.0, horizon_months=12)
|
||
with forecast_cache():
|
||
fn(_DB, "66:41:0702048:27", req)
|
||
fn(_DB, "66:41:0702048:27", req)
|
||
assert counter.calls == 1
|
||
|
||
def test_competitors_differ_by_horizon_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
|
||
with forecast_cache():
|
||
fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=6))
|
||
fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=24))
|
||
assert counter.calls == 2
|
||
|
||
def test_competitors_differ_by_radius_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
|
||
with forecast_cache():
|
||
fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=0.5))
|
||
fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=1.5))
|
||
assert counter.calls == 2
|
||
|
||
def test_competitors_differ_by_cad_num_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
|
||
req = CompetitorsRequest()
|
||
with forecast_cache():
|
||
fn(_DB, "66:41:0702048:27", req)
|
||
fn(_DB, "66:41:0702048:99", req)
|
||
assert counter.calls == 2
|
||
|
||
# ── compute_future_supply_pressure: horizon_months обязан различать ────────
|
||
|
||
def test_future_supply_same_once(self) -> None:
|
||
fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
|
||
with forecast_cache():
|
||
fn(_DB, district="Ленинский", horizon_months=12)
|
||
fn(_DB, district="Ленинский", horizon_months=12)
|
||
assert counter.calls == 1
|
||
|
||
def test_future_supply_differ_by_horizon_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
|
||
with forecast_cache():
|
||
fn(_DB, district="Ленинский", horizon_months=6)
|
||
fn(_DB, district="Ленинский", horizon_months=24)
|
||
assert counter.calls == 2
|
||
|
||
def test_future_supply_differ_by_district_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
|
||
with forecast_cache():
|
||
fn(_DB, district="Ленинский", horizon_months=12)
|
||
fn(_DB, district="Кировский", horizon_months=12)
|
||
assert counter.calls == 2
|
||
|
||
# ── compute_market_metrics: window_months обязан различать ─────────────────
|
||
|
||
def test_market_metrics_same_once(self) -> None:
|
||
fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
|
||
with forecast_cache():
|
||
fn(_DB, district="Ленинский", window_months=6)
|
||
fn(_DB, district="Ленинский", window_months=6)
|
||
assert counter.calls == 1
|
||
|
||
def test_market_metrics_differ_by_window_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
|
||
with forecast_cache():
|
||
fn(_DB, district="Ленинский", window_months=6)
|
||
fn(_DB, district="Ленинский", window_months=12)
|
||
assert counter.calls == 2
|
||
|
||
def test_market_metrics_differ_by_obj_ids_twice(self) -> None:
|
||
fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
|
||
with forecast_cache():
|
||
fn(_DB, district=None, obj_ids=[1, 2, 3])
|
||
fn(_DB, district=None, obj_ids=[1, 2, 4])
|
||
assert counter.calls == 2
|
||
|
||
# ── compute_demand_normalization: rate_future обязан различать (lambda-ключ) ─
|
||
|
||
def test_demand_norm_same_once(self) -> None:
|
||
# Тот же inline-lambda-ключ, что навешен на продакшн compute_demand_normalization.
|
||
fn, counter = _spy_with_key(
|
||
lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
|
||
label="compute_demand_normalization",
|
||
)
|
||
spec = SegmentSpec(obj_class="Комфорт")
|
||
with forecast_cache():
|
||
fn(_DB, spec=spec, rate_future=18.0)
|
||
fn(_DB, spec=spec, rate_future=18.0)
|
||
assert counter.calls == 1
|
||
|
||
def test_demand_norm_differ_by_rate_future_twice(self) -> None:
|
||
fn, counter = _spy_with_key(
|
||
lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
|
||
label="compute_demand_normalization",
|
||
)
|
||
spec = SegmentSpec(obj_class="Комфорт")
|
||
with forecast_cache():
|
||
fn(_DB, spec=spec, rate_future=14.0) # conservative
|
||
fn(_DB, spec=spec, rate_future=20.0) # aggressive
|
||
assert counter.calls == 2
|
||
|
||
def test_demand_norm_differ_by_spec_twice(self) -> None:
|
||
fn, counter = _spy_with_key(
|
||
lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
|
||
label="compute_demand_normalization",
|
||
)
|
||
with forecast_cache():
|
||
fn(_DB, spec=SegmentSpec(obj_class="Комфорт"), rate_future=18.0)
|
||
fn(_DB, spec=SegmentSpec(obj_class="Бизнес"), rate_future=18.0)
|
||
assert counter.calls == 2
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 1c. Прямые проверки key-builder'ов: key(A)==key(A), key(A)!=key(B) по каждому
|
||
# влияющему на результат аргументу. Локирует контракт ключа без кэша вообще.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestKeyBuildersDirect:
|
||
def test_competitors_key_stable_and_discriminating(self) -> None:
|
||
base = CompetitorsRequest(
|
||
radius_km=1.0,
|
||
time_window="last_quarter",
|
||
obj_class_filter=None,
|
||
horizon_months=12,
|
||
exclude_obj_ids=[],
|
||
)
|
||
cad = "66:41:0702048:27"
|
||
# стабилен и hashable (кэш кладёт ключ в dict)
|
||
assert _competitors_key(_DB, cad, base) == _competitors_key(_DB, cad, base)
|
||
assert hash(_competitors_key(_DB, cad, base))
|
||
# каждый влияющий аргумент различает
|
||
assert _competitors_key(_DB, cad, base) != _competitors_key(_DB, "66:41:0702048:99", base)
|
||
assert _competitors_key(_DB, cad, base) != _competitors_key(
|
||
_DB, cad, base.model_copy(update={"radius_km": 1.5})
|
||
)
|
||
assert _competitors_key(_DB, cad, base) != _competitors_key(
|
||
_DB, cad, base.model_copy(update={"horizon_months": 24})
|
||
)
|
||
assert _competitors_key(_DB, cad, base) != _competitors_key(
|
||
_DB, cad, base.model_copy(update={"time_window": "last_month"})
|
||
)
|
||
|
||
def test_competitors_key_db_ignored(self) -> None:
|
||
"""db-сессия НЕ входит в ключ (одна сессия на отчёт) — разные db → тот же ключ."""
|
||
req = CompetitorsRequest()
|
||
db_a: Any = MagicMock(name="session_a")
|
||
db_b: Any = MagicMock(name="session_b")
|
||
assert _competitors_key(db_a, "x", req) == _competitors_key(db_b, "x", req)
|
||
|
||
def test_competitors_key_exclude_order_invariant(self) -> None:
|
||
"""exclude_obj_ids сортируется → порядок не влияет на ключ (устойчивость)."""
|
||
cad = "66:41:0702048:27"
|
||
a = CompetitorsRequest(exclude_obj_ids=[3, 1, 2])
|
||
b = CompetitorsRequest(exclude_obj_ids=[1, 2, 3])
|
||
assert _competitors_key(_DB, cad, a) == _competitors_key(_DB, cad, b)
|
||
|
||
def test_future_supply_key_stable_and_discriminating(self) -> None:
|
||
k = _future_supply_key
|
||
assert k(_DB, district="Л", horizon_months=12) == k(_DB, district="Л", horizon_months=12)
|
||
assert hash(k(_DB, district="Л", horizon_months=12))
|
||
# горизонт ОБЯЗАН различать (взвешивание L3 горизонт-зависимо)
|
||
assert k(_DB, district="Л", horizon_months=6) != k(_DB, district="Л", horizon_months=24)
|
||
assert k(_DB, district="Л", horizon_months=12) != k(_DB, district="К", horizon_months=12)
|
||
assert k(_DB, district="Л", horizon_months=12) != k(
|
||
_DB, district="Л", horizon_months=12, premise_kind="апартаменты"
|
||
)
|
||
|
||
def test_market_metrics_key_stable_and_discriminating(self) -> None:
|
||
k = _market_metrics_key
|
||
assert k(_DB, district="Л", window_months=6) == k(_DB, district="Л", window_months=6)
|
||
assert hash(k(_DB, district="Л", window_months=6))
|
||
assert k(_DB, district="Л", window_months=6) != k(_DB, district="Л", window_months=12)
|
||
assert k(_DB, district="Л") != k(_DB, district="К")
|
||
assert k(_DB, obj_ids=[1, 2]) != k(_DB, obj_ids=[1, 3])
|
||
# None obj_ids vs пустой набор — обе ветки hashable, None отличается от tuple()
|
||
assert k(_DB, district="Л", obj_ids=None) != k(_DB, district="Л", obj_ids=[1])
|
||
|
||
def test_market_metrics_key_obj_ids_order_sensitive_but_hashable(self) -> None:
|
||
"""obj_ids → tuple(...) (без sort): hashable; в форсайт-пути obj_ids всегда None."""
|
||
k = _market_metrics_key
|
||
# tuple сохраняет порядок — проверяем лишь hashability/стабильность одинакового входа
|
||
assert k(_DB, obj_ids=[1, 2, 3]) == k(_DB, obj_ids=[1, 2, 3])
|
||
assert hash(k(_DB, obj_ids=[1, 2, 3]))
|
||
|
||
def test_sales_series_key_stable_and_discriminating(self) -> None:
|
||
k = _sales_series_key
|
||
spec = SegmentSpec(obj_class="Комфорт", district="Ленинский")
|
||
# стабилен и hashable
|
||
assert k(_DB, spec=spec, source="corpus_room_month") == k(
|
||
_DB, spec=spec, source="corpus_room_month"
|
||
)
|
||
assert hash(k(_DB, spec=spec, source="corpus_room_month"))
|
||
# каждый влияющий аргумент различает
|
||
assert k(_DB, spec=spec, source="corpus_room_month") != k(
|
||
_DB, spec=spec, source="objective_lots"
|
||
)
|
||
assert k(_DB, spec=spec, source="corpus_room_month") != k(
|
||
_DB, spec=spec, source="corpus_room_month", months_back=24
|
||
)
|
||
assert k(_DB, spec=spec, source="corpus_room_month") != k(
|
||
_DB, spec=spec, source="corpus_room_month", premise_kind="апартаменты"
|
||
)
|
||
assert k(_DB, spec=spec, source="corpus_room_month") != k(
|
||
_DB,
|
||
spec=SegmentSpec(obj_class="Бизнес", district="Ленинский"),
|
||
source="corpus_room_month",
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 2. No-op вне активного контекста.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestNoOpOutsideContext:
|
||
def test_no_context_calls_underlying_every_time(self) -> None:
|
||
"""Вне `forecast_cache()` → тело вызывается КАЖДЫЙ раз (мемоизации нет)."""
|
||
fn, counter = _spy_with_key(lambda x: x, label="fake")
|
||
r1 = fn(1)
|
||
r2 = fn(1)
|
||
assert counter.calls == 2
|
||
assert r1 is not r2 # без кэша — каждый раз новый инстанс
|
||
|
||
def test_is_active_false_outside_true_inside(self) -> None:
|
||
assert is_active() is False
|
||
with forecast_cache():
|
||
assert is_active() is True
|
||
assert is_active() is False
|
||
|
||
def test_is_active_resets_even_on_exception(self) -> None:
|
||
"""`forecast_cache()` сбрасывает ContextVar даже если внутри блока бросили."""
|
||
with pytest.raises(RuntimeError, match="boom"):
|
||
with forecast_cache():
|
||
assert is_active() is True
|
||
raise RuntimeError("boom")
|
||
assert is_active() is False # token reset в finally — нет утечки контекста
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 3. Изоляция per-отчёт + реентерабельность.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestPerReportIsolationAndReentrancy:
|
||
def test_sequential_blocks_do_not_share(self) -> None:
|
||
"""Два ПОСЛЕДОВАТЕЛЬНЫХ контекста → 2-й пересчитывает (кэш отброшен на выходе)."""
|
||
fn, counter = _spy_with_key(lambda x: x, label="fake")
|
||
with forecast_cache():
|
||
fn(1)
|
||
assert counter.calls == 1
|
||
with forecast_cache():
|
||
fn(1) # новый кэш — НЕ видит запись из 1-го блока
|
||
assert counter.calls == 2
|
||
|
||
def test_entries_discarded_on_exit(self) -> None:
|
||
"""Кэш-dict, выданный контекстом, на выходе перестаёт быть активным."""
|
||
first_seen: dict[Any, Any] | None = None
|
||
with forecast_cache() as c1:
|
||
first_seen = c1
|
||
assert is_active() is True
|
||
assert is_active() is False
|
||
# повторный вход даёт СВЕЖИЙ dict (не тот же объект)
|
||
with forecast_cache() as c2:
|
||
assert c2 is not first_seen
|
||
|
||
def test_nested_context_shares_active_cache(self) -> None:
|
||
"""Вложенный `forecast_cache()` переиспользует активный кэш (один вызов на блок)."""
|
||
fn, counter = _spy_with_key(lambda x: x, label="fake")
|
||
with forecast_cache():
|
||
fn(1)
|
||
with forecast_cache(): # реентерабельно — НЕ заводит новый кэш
|
||
fn(1) # обслужено из внешнего кэша
|
||
fn(1)
|
||
assert counter.calls == 1
|
||
|
||
def test_nested_context_yields_same_dict(self) -> None:
|
||
"""Реентерабельность отдаёт ТОТ ЖЕ dict (не подменяет накопленное)."""
|
||
with forecast_cache() as outer:
|
||
with forecast_cache() as inner:
|
||
assert inner is outer
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 4. Канарейка неизменяемости: все мемоизируемые продьюсеры → frozen-dataclass.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Шаринг кэш-инстанса безопасен ТОЛЬКО пока результат неизменяем. Если кто-то снимет
|
||
# frozen=True (frozen→mutable), callers смогут случайно мутировать общий инстанс →
|
||
# тихая порча отчёта. Этот тест ловит такое изменение типа на корню.
|
||
_FROZEN_PRODUCERS = [
|
||
MarketMetrics,
|
||
FutureSupplyPressure,
|
||
DemandNormalization,
|
||
RateSensitivity,
|
||
MacroCoefficient,
|
||
SalesSeries,
|
||
MonthlyMacro,
|
||
SegmentSpec, # ключевой вход в ключ кэша — обязан быть hashable/frozen
|
||
]
|
||
|
||
|
||
class TestMutationSafetyCanary:
|
||
@pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__)
|
||
def test_producer_is_frozen_dataclass(self, cls: type) -> None:
|
||
assert dataclasses.is_dataclass(cls), f"{cls.__name__} перестал быть dataclass"
|
||
params = cls.__dataclass_params__ # type: ignore[attr-defined]
|
||
assert params.frozen is True, (
|
||
f"{cls.__name__} больше НЕ frozen — шаринг кэш-инстанса (#1129) стал "
|
||
f"небезопасен: callers могут мутировать общий объект. Пересмотри кэш."
|
||
)
|
||
|
||
@pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__)
|
||
def test_frozen_instance_rejects_attr_set(self, cls: type) -> None:
|
||
"""Frozen реально блокирует мутацию поля (поведенческая проверка, не только флаг)."""
|
||
fields = dataclasses.fields(cls)
|
||
assert fields, f"{cls.__name__} без полей — нечего проверять"
|
||
# Сконструировать инстанс не можем без валидных значений → берём поле и убеждаемся,
|
||
# что КЛАСС объявлен frozen на уровне dataclass-параметров (флаг проверен выше);
|
||
# дополнительно фиксируем, что FrozenInstanceError — ожидаемый класс ошибки.
|
||
assert issubclass(dataclasses.FrozenInstanceError, AttributeError)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 5. Amplification-proof: реальная загрузка get_monthly_macro (БД-хелперы — спаи).
|
||
# Повторный паттерн вызовов в одном отчёте → ОДИН реальный roundtrip (×N→1).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
_MACRO_KEYRATE = "app.services.forecasting.macro_series._query_key_rate_monthly"
|
||
_MACRO_MORTGAGE = "app.services.forecasting.macro_series._query_mortgage_monthly"
|
||
|
||
|
||
@pytest.fixture
|
||
def _macro_db_spies() -> Iterator[dict[str, Any]]:
|
||
"""Патчим ОБА БД-хелпера get_monthly_macro пустыми спаями (без живой БД).
|
||
|
||
get_monthly_macro строит сетку месяцев из системной даты и для каждого месяца зовёт
|
||
_query_key_rate_monthly + _query_mortgage_monthly РОВНО по разу. Спаи возвращают
|
||
пустые dict'ы → ряд из MonthlyMacro с None-полями (graceful-путь), реальных
|
||
roundtrip'ов = число НЕ-закэшированных вызовов get_monthly_macro.
|
||
"""
|
||
with (
|
||
patch(_MACRO_KEYRATE, return_value={}) as kr,
|
||
patch(_MACRO_MORTGAGE, return_value={}) as mg,
|
||
):
|
||
yield {"key_rate": kr, "mortgage": mg}
|
||
|
||
|
||
class TestAmplificationProofRealLoader:
|
||
def test_repeated_calls_collapse_to_one_roundtrip(
|
||
self, _macro_db_spies: dict[str, Any]
|
||
) -> None:
|
||
"""ВНУТРИ одного отчёта 5 одинаковых get_monthly_macro → 1 пара БД-roundtrip'ов."""
|
||
with forecast_cache():
|
||
results = [get_monthly_macro(_DB, months_back=48) for _ in range(5)]
|
||
# Реальная загрузка ушла в БД ровно один раз (×5 → ×1 — суть #1129).
|
||
assert _macro_db_spies["key_rate"].call_count == 1
|
||
assert _macro_db_spies["mortgage"].call_count == 1
|
||
# И все 5 вызовов вернули ТОТ ЖЕ инстанс (frozen, read-only → шаринг безопасен).
|
||
assert all(r is results[0] for r in results)
|
||
|
||
def test_different_months_back_not_collapsed(self, _macro_db_spies: dict[str, Any]) -> None:
|
||
"""Разный months_back → разный ключ → 2 реальных roundtrip-пары (нет коллизии)."""
|
||
with forecast_cache():
|
||
get_monthly_macro(_DB, months_back=48)
|
||
get_monthly_macro(_DB, months_back=24)
|
||
assert _macro_db_spies["key_rate"].call_count == 2
|
||
assert _macro_db_spies["mortgage"].call_count == 2
|
||
|
||
def test_real_loader_noop_outside_context(self, _macro_db_spies: dict[str, Any]) -> None:
|
||
"""ВНЕ контекста реальная загрузка НЕ кэшируется → 2 вызова → 2 roundtrip-пары."""
|
||
get_monthly_macro(_DB, months_back=48)
|
||
get_monthly_macro(_DB, months_back=48)
|
||
assert _macro_db_spies["key_rate"].call_count == 2
|
||
assert _macro_db_spies["mortgage"].call_count == 2
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Sanity: продакшн-загрузки реально обёрнуты @cached (functools.wraps сохраняет имя,
|
||
# но навешивает обёртку) — ловит «декоратор случайно сняли с функции».
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestLoadersAreDecorated:
|
||
@pytest.mark.parametrize(
|
||
"loader",
|
||
[
|
||
get_competitors,
|
||
compute_future_supply_pressure,
|
||
compute_market_metrics,
|
||
compute_demand_normalization,
|
||
compute_rate_regime_sensitivity,
|
||
build_sales_series,
|
||
compute_macro_coefficient,
|
||
get_monthly_macro,
|
||
],
|
||
ids=lambda f: f.__name__,
|
||
)
|
||
def test_loader_is_cached_wrapped(self, loader: Any) -> None:
|
||
# @wraps сохраняет __wrapped__ ссылку на оригинал → присутствие = задекорировано.
|
||
assert hasattr(loader, "__wrapped__"), (
|
||
f"{loader.__name__} больше не обёрнут @cached — мемоизация #1129 отвалилась"
|
||
)
|