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