perf(forecast): per-request memoization cache — §22 cold build (#1129) #1160

Merged
bot-backend merged 1 commit from perf/forecast-parallel-horizons-1129 into main 2026-06-08 05:26:28 +00:00
11 changed files with 875 additions and 0 deletions

View file

@ -0,0 +1,135 @@
"""Request-scoped memoization для §22-форсайта (#1129) — БЕЗ смены сигнатур сервисов.
ПРОБЛЕМА (профиль #1129): один холодный §22-отчёт (`build_site_finder_report`,
orchestrator.py) собирает 8 §9.x-слоёв, и эти слои МАССОВО пере-вызывают ОДНИ И ТЕ ЖЕ
горизонт-инвариантные БД-загрузки с ИДЕНТИЧНЫМИ аргументами. Пример амплификации на
один отчёт (сетка 6/12/18/24, default what_to_build-сетка 3×5):
`compute_scenarios` гоняет `compute_demand_supply_forecast` ТРИЖДЫ (conservative/
base/aggressive) × 4 горизонта;
`special_indices`/`recommendation` дважды зовут `rank_segments` 15 ячеек ×
`compute_demand_supply_forecast`;
КАЖДАЯ `compute_demand_supply_forecast`-ячейка внутри зовёт §9.4
`compute_demand_normalization` ( §9.6 `compute_rate_regime_sensitivity`
`get_monthly_macro` + 2× `build_sales_series`) + §9.3
`compute_future_supply_pressure` ( `compute_market_metrics`) + §9.7
`get_competitors`.
Итог сотни повторных roundtrip'ов к БД, почти все с СОВПАДАЮЩИМИ аргументами
(один и тот же `district`, один и тот же `spec`, один и тот же `cad_num`,
горизонт-инвариантный макро-ряд). Это case (c) аудита #1129: ОБЩАЯ дорогая загрузка
пере-выполняется N раз мемоизировать её ОДИН раз на отчёт убирает дублирование БЕЗ
конкурентности и БЕЗ изменения результата.
РЕШЕНИЕ: per-request (per-отчёт) кэш на `ContextVar`. Контекст-менеджер
`forecast_cache()` активирует свежий dict на время сборки ОДНОГО отчёта; декоратор
`cached(...)` оборачивает дорогие §9.x-сервисы и:
ВНУТРИ активного контекста результат считается один раз на уникальный ключ
аргументов и переиспользуется (тот же объект все наши результаты frozen-dataclass
либо read-only-список frozen-dataclass'ов, см. ниже: callers их НЕ мутируют, поэтому
шаринг инстанса детерминирован и безопасен);
ВНЕ контекста (обычные standalone-вызовы: API `/competitors`, юнит-тесты, прочие
эндпоинты) функция выполняется КАК ОБЫЧНО, без кэша. Нулевое изменение поведения
за пределами сборки §22-отчёта.
ПОЧЕМУ ContextVar, а не аргумент-кэш: не надо протаскивать cache-параметр через ~10
гетерогенных сигнатур §9.x. ContextVar автоматически скоупится сборкой отчёта и
изолирован per-thread/per-task (важно, если позже сборка уйдёт в ThreadPool у
каждого потока свой контекст; здесь же §22 идёт в одном Celery-`def`-воркере).
КОРРЕКТНОСТЬ (жёсткий гейт #1129): кэш возвращает ТОТ ЖЕ инстанс для совпадающих
ключей. Все мемоизируемые продьюсеры возвращают НЕИЗМЕНЯЕМЫЕ значения
`@dataclass(frozen=True)` (MarketMetrics / FutureSupplyPressure / DemandSupplyForecast /
DemandNormalization / RateSensitivity / MacroCoefficient / SalesSeries / MonthlyMacro) и
Pydantic-модель CompetitorsResponse а вызывающий код их только ЧИТАЕТ (атрибуты,
list-comprehension, `for`), нигде не мутирует список/поля на месте. Поэтому шаринг
кэш-инстанса даёт ПОБАЙТОВО тот же собранный отчёт, что и без кэша.
Ключи строятся ЯВНЫМ key-builder'ом на каждую функцию (не «магический» по сигнатуре):
нормализуем только те аргументы, что РЕАЛЬНО влияют на результат, в hashable-tuple.
`db`-сессия в ключ НЕ входит (на время отчёта она одна; данные за сборку не меняются).
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import wraps
from typing import Any, TypeVar, cast
logger = logging.getLogger(__name__)
# Активный per-отчёт кэш (или None вне сборки отчёта). Хранит {ключ: результат}.
# default=None ⇔ «контекст не активен» → cached(...) выполняет функцию без кэша.
_CACHE: ContextVar[dict[Any, Any] | None] = ContextVar("forecast_request_cache", default=None)
F = TypeVar("F", bound=Callable[..., Any])
# Сентинел для «значение отсутствует» — отличаем закэшированный None от промаха.
_MISS = object()
@contextmanager
def forecast_cache() -> Iterator[dict[Any, Any]]:
"""Активировать свежий per-отчёт кэш на время блока (один §22-отчёт).
Внутри блока все `cached(...)`-сервисы мемоизируются в общий dict; на выходе он
отбрасывается (кэш живёт ровно одну сборку отчёта никакой кросс-отчётной утечки
устаревших данных). Реентерабельность: вложенный `forecast_cache()` НЕ заводит
новый кэш переиспользует уже активный (idempotent), чтобы мемоизация была общей
на всю сборку, даже если вход обёрнут дважды.
"""
existing = _CACHE.get()
if existing is not None:
# Уже внутри активного контекста — переиспользуем (не сбрасываем накопленное).
yield existing
return
cache: dict[Any, Any] = {}
token = _CACHE.set(cache)
try:
yield cache
finally:
_CACHE.reset(token)
logger.debug("forecast_cache: closed (entries=%d)", len(cache))
def cached(key: Callable[..., Any], *, label: str) -> Callable[[F], F]:
"""Декоратор: мемоизировать результат функции per-отчёт по явному ключу аргументов.
Args:
key: builder ключа принимает те же *args/**kwargs, что обёрнутая функция
(включая `db` первым позиционным его игнорируем), и возвращает hashable
идентификатор того, что РЕАЛЬНО влияет на результат. Должен быть
детерминирован и полностью покрывать варьирующиеся входы.
label: короткое имя для namespacing ключа и для debug-лога (имя функции).
Returns:
Декоратор, оборачивающий sync-функцию `(db, ...) -> Result`.
Вне активного `forecast_cache()` обёртка просто зовёт оригинал (без кэша) обычные
standalone-вызовы (API/тесты) не затрагиваются.
"""
def decorate(fn: F) -> F:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
cache = _CACHE.get()
if cache is None:
# Нет активного контекста → поведение по умолчанию, без мемоизации.
return fn(*args, **kwargs)
cache_key = (label, key(*args, **kwargs))
hit = cache.get(cache_key, _MISS)
if hit is not _MISS:
return hit
result = fn(*args, **kwargs)
cache[cache_key] = result
return result
return cast(F, wrapper)
return decorate
def is_active() -> bool:
"""Активен ли сейчас per-отчёт кэш (для диагностики/тестов). PURE-ish."""
return _CACHE.get() is not None

View file

@ -56,6 +56,7 @@ from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
from app.services.forecasting.regression import compute_rate_regime_sensitivity
from app.services.forecasting.sales_series import SegmentSpec
@ -224,6 +225,16 @@ def _neutral(
# ──────────────────────────────────────────────────────────────────────────────
@cached(
# rate_future ВЛИЯЕТ на coefficient → обязан быть в ключе (иначе разные сценарные
# ставки схлопнулись бы в один norm — это ИЗМЕНИЛО бы результат).
lambda db, *, spec, rate_future, months_back=_DEFAULT_MONTHS_BACK: (
spec,
rate_future,
months_back,
),
label="compute_demand_normalization",
)
def compute_demand_normalization(
db: Session,
*,

View file

@ -64,6 +64,7 @@ from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import (
MonthlyMacro,
get_monthly_macro,
@ -637,6 +638,22 @@ def _confidence(*, n_backed_available: int, confounded: bool) -> Confidence:
# ──────────────────────────────────────────────────────────────────────────────
def _macro_coefficient_key(
db: Session,
*,
segment_profile: dict[str, Any] | None = None,
months_back: int = _DEFAULT_MONTHS_BACK,
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_macro_coefficient (#1129).
segment_profile dict детерминированный sorted-tuple пар (hashable). + окно.
`db` не в ключе (одна сессия на отчёт).
"""
profile_key = tuple(sorted(segment_profile.items())) if segment_profile else ()
return (profile_key, months_back)
@cached(_macro_coefficient_key, label="compute_macro_coefficient")
def compute_macro_coefficient(
db: Session,
*,

View file

@ -37,6 +37,7 @@ from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.site_finder.macro import get_macro_series
logger = logging.getLogger(__name__)
@ -276,6 +277,7 @@ _KEY_RATE_MONTHLY_SQL = text(
)
@cached(lambda db, *, months_back=_DEFAULT_MONTHS_BACK: months_back, label="get_monthly_macro")
def get_monthly_macro(
db: Session, *, months_back: int = _DEFAULT_MONTHS_BACK
) -> list[MonthlyMacro]:

View file

@ -46,6 +46,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import forecast_cache
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.product_scoring import compute_score_card
@ -211,6 +212,37 @@ def build_site_finder_report(
) -> SiteFinderReport:
"""Посчитать §9.x-слои и собрать §22 `SiteFinderReport` для участка (ОРКЕСТРАЦИЯ).
PERF (#1129): активируем per-отчёт кэш (`forecast_cache`) на ВСЮ сборку. §9.x-слои
массово пере-вызывают одни и те же горизонт/сегмент-инвариантные БД-загрузки с
совпадающими аргументами (профиль: get_competitors ×69, market_metrics ×124,
get_monthly_macro ×290 на один отчёт) кэш считает каждую уникальную загрузку ОДИН
раз и переиспользует тот же (frozen, read-only) результат. Вывод ПОБАЙТОВО идентичен
несжатому пути (детерминизм §16/§26 сохранён); ускорение чисто за счёт устранения
дублирующих roundtrip'ов, БЕЗ конкурентности. Кэш живёт ровно одну сборку (на выходе
из `with` отбрасывается никакой кросс-отчётной утечки).
"""
with forecast_cache():
return _build_site_finder_report_impl(
db,
analyze=analyze,
cad_num=cad_num,
district=district,
obj_class=obj_class,
horizons=horizons,
)
def _build_site_finder_report_impl(
db: Session,
*,
analyze: dict[str, Any],
cad_num: str,
district: str | None,
obj_class: str | None = None,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> SiteFinderReport:
"""Реальная сборка §22-отчёта (тело #1056). Вызывается ВНУТРИ `forecast_cache()`.
ШОВ между §9.x-сервисами (которые ходят в БД) и ЧИСТЫМ §22-сборщиком (#988
`assemble_report`, который только раскладывает уже-посчитанные входы). НЕ новая
§9.x-математика только проводка: считаем слои раскладываем в сборщик.

View file

@ -53,6 +53,7 @@ from typing import Any, Literal
import numpy as np
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import (
@ -710,6 +711,10 @@ def _fit_to_sensitivity(
)
@cached(
lambda db, *, spec, months_back=_DEFAULT_MONTHS_BACK: (spec, months_back),
label="compute_rate_regime_sensitivity",
)
def compute_rate_regime_sensitivity(
db: Session,
*,

View file

@ -57,6 +57,7 @@ from sqlalchemy.orm import Session
# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
# macro_series, хоть и не в __all__ пакета.
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
from app.services.site_finder.district_resolver import resolve_objective_districts
@ -441,6 +442,23 @@ _SOURCE_B_SQL = text(
_DEFAULT_PREMISE_KIND: str = "квартира"
def _sales_series_key(
db: Session,
*,
spec: SegmentSpec,
source: SalesSource,
months_back: int = _DEFAULT_MONTHS_BACK,
premise_kind: str = _DEFAULT_PREMISE_KIND,
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для build_sales_series (#1129).
SegmentSpec frozen-dataclass (hashable напрямую); source/premise строки;
+ окно. `db` не в ключе (одна сессия на отчёт).
"""
return (spec, source, months_back, premise_kind)
@cached(_sales_series_key, label="build_sales_series")
def build_sales_series(
db: Session,
*,

View file

@ -28,9 +28,28 @@ from app.schemas.parcel import (
CompetitorsResponse,
CompetitorsSummary,
)
from app.services.forecast_request_cache import cached
logger = logging.getLogger(__name__)
def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
Все поля CompetitorsRequest, изменяющие результат (radius/time_window/класс-фильтр/
horizon_months/exclude_obj_ids), + cad_num. exclude_obj_ids список в tuple для
hashability (его порядок на вывод не влияет, но в форсайт-пути он всегда пуст
сортируем для устойчивости ключа). `db` в ключ не входит (одна сессия на отчёт).
"""
return (
cad_num,
request.radius_km,
request.time_window,
request.obj_class_filter,
request.horizon_months,
tuple(sorted(request.exclude_obj_ids)),
)
# Маппинг time_window → число месяцев (float для деления velocity)
_TIME_WINDOW_MONTHS: dict[str, float] = {
"last_month": 1.0,
@ -439,6 +458,7 @@ def _quarter_from_cad(cad_num: str) -> str:
return cad_num
@cached(_competitors_key, label="get_competitors")
def get_competitors(
db: Session,
cad_num: str,

View file

@ -52,11 +52,28 @@ from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.site_finder.district_resolver import resolve_objective_districts
from app.services.site_finder.market_metrics import compute_market_metrics
logger = logging.getLogger(__name__)
def _future_supply_key(
db: Session,
*,
district: str | None,
horizon_months: int = 12,
premise_kind: str = "квартира",
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_future_supply_pressure (#1129).
ГОРИЗОНТ-ЗАВИСИМ: `_horizon_weight` взвешивает L3 по horizon_months горизонт
обязан быть в ключе (иначе все горизонты схлопнулись бы в один это ИЗМЕНИЛО бы
результат). + district + premise_kind. `db` не в ключе.
"""
return (district, horizon_months, premise_kind)
Confidence = Literal["high", "medium", "low"]
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
@ -292,6 +309,7 @@ _SUPPLY_LAYERS_SQL = text(
)
@cached(_future_supply_key, label="compute_future_supply_pressure")
def compute_future_supply_pressure(
db: Session,
*,

View file

@ -38,10 +38,33 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.analytics_queries import _elasticity_coef
from app.services.forecast_request_cache import cached
from app.services.site_finder.district_resolver import resolve_objective_districts
logger = logging.getLogger(__name__)
def _market_metrics_key(
db: Session,
*,
district: str | None = None,
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None tuple()
устойчив. `db` не в ключе (одна сессия на отчёт).
"""
return (
district,
tuple(obj_ids) if obj_ids is not None else None,
window_months,
premise_kind,
)
Confidence = Literal["high", "medium", "low"]
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
@ -315,6 +338,7 @@ _SALES_WINDOW_SQL = text(
)
@cached(_market_metrics_key, label="compute_market_metrics")
def compute_market_metrics(
db: Session,
*,

View file

@ -0,0 +1,593 @@
"""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 демонстрация ×N1 редукции,
ради которой кэш и сделан (профиль: 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 отвалилась"
)