From 97ff0c9e8157ffdf4d02b81646474a8a2ac4d715 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 12 Jun 2026 19:58:07 +0500 Subject: [PATCH] =?UTF-8?q?perf(site-finder):=20TTL-=D0=BA=D1=8D=D1=88=20O?= =?UTF-8?q?pen-Meteo=20=D0=B2=20analyze=20hot-path=20(#1130=20Phase=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два внешних HTTP-вызова в analyze (_fetch_weather_sync + _fetch_seasonal_weather_sync) сидели без кэша. На проде в private network с restricted egress DNS до *.open-meteo.com фейлит и каждый replay висит полный timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfile одного replay: 2.38с в httpx + 1.73с в SSL recv. Вынес логику в app/services/weather_cache.py: - TTL hit: forecast 6h, climate normals 7d. Negative-cache: 5min (DNS-fail не повторяет timeout на каждый analyze, восстановление подхватывается через ~5 минут). - Ключ — округлённые до 0.01° (~1 км) координаты. - Single-flight: per-cache threading.Lock + check-then-fetch-then-store. - Тайм-ауты сокращены: forecast 5s→2s, climate 15s→3s. Контракт для caller'а не изменился: None по-прежнему допустим (env_ok-флаг в _build_environmental_score). 7 файлов API-тестов обновили patch-цели на новые имена get_weather_cached / get_seasonal_weather_cached. 11 новых юнит-тестов в tests/services/test_weather_cache.py (single-flight через threading.Barrier(16), TTL expire через monkeypatch _now, изоляция forecast vs climate). Refs #1130 --- backend/app/api/v1/parcels.py | 154 +------- backend/app/services/weather_cache.py | 278 ++++++++++++++ .../api/v1/test_analyze_inline_weights.py | 4 +- .../tests/api/v1/test_analyze_market_price.py | 4 +- .../tests/api/v1/test_analyze_parcel_meta.py | 4 +- .../api/v1/test_analyze_recent_permits.py | 4 +- backend/tests/api/v1/test_custom_pois.py | 4 +- backend/tests/api/v1/test_parcels_forecast.py | 4 +- .../test_run_history_and_response_contract.py | 4 +- backend/tests/services/test_weather_cache.py | 340 ++++++++++++++++++ 10 files changed, 640 insertions(+), 160 deletions(-) create mode 100644 backend/app/services/weather_cache.py create mode 100644 backend/tests/services/test_weather_cache.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index d99173a4..3c21ff7b 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -87,6 +87,10 @@ from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import ( resolve_weights as _resolve_weights, ) +from app.services.weather_cache import ( + get_seasonal_weather_cached, + get_weather_cached, +) logger = logging.getLogger(__name__) @@ -147,149 +151,6 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None: return None -def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None: - """Open-Meteo Climate API — 30-летние нормали по сезонам. - - Использует модель MRI-AGCM3.2-S (японская, точная для всего мира). - Группирует 1995-2024 по четырём сезонам. Медленнее прогноза — timeout 15s. - """ - try: - with httpx.Client(timeout=15) as c: - r = c.get( - "https://climate-api.open-meteo.com/v1/climate", - params={ - "latitude": lat, - "longitude": lon, - "start_date": "1995-01-01", - "end_date": "2024-12-31", - "models": "MRI_AGCM3_2_S", - "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum", - }, - ) - r.raise_for_status() - data = r.json() - daily = data.get("daily", {}) - times = daily.get("time") or [] - t_max = daily.get("temperature_2m_max") or [] - t_min = daily.get("temperature_2m_min") or [] - precip = daily.get("precipitation_sum") or [] - if not times: - return None - - seasons_months = { - "winter": [12, 1, 2], - "spring": [3, 4, 5], - "summer": [6, 7, 8], - "autumn": [9, 10, 11], - } - buckets: dict[str, dict[str, list[float]]] = { - k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months - } - for i, t in enumerate(times): - month = int(t[5:7]) # 'YYYY-MM-DD' - for season, months in seasons_months.items(): - if month in months: - if i < len(t_max) and t_max[i] is not None: - buckets[season]["t_max"].append(t_max[i]) - if i < len(t_min) and t_min[i] is not None: - buckets[season]["t_min"].append(t_min[i]) - if i < len(precip) and precip[i] is not None: - buckets[season]["precip"].append(precip[i]) - break - - seasons: dict[str, Any] = {} - for season, vals in buckets.items(): - if not vals["t_max"]: - seasons[season] = None - continue - seasons[season] = { - "avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1), - "avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1), - "max_t_c": round(max(vals["t_max"]), 1), - "min_t_c": round(min(vals["t_min"]), 1), - "avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1), - "total_precip_mm": round(sum(vals["precip"]), 0), - "days_observed": len(vals["t_max"]), - } - return { - "seasons": seasons, - "period": "1995-2024 (30 лет)", - "model": "MRI-AGCM3-2-S", - "source": "open-meteo-climate", - "note": ("Климатические нормали. Текущая погода — отдельный API."), - } - except Exception as e: - logger.warning("seasonal weather fetch failed: %s", e) - return None - - -def _fetch_weather_sync(lat: float, lon: float) -> dict | None: - """Open-Meteo Forecast API — 7-day weather + climate context. - - Free, no API key, JSON by coordinates. Покрывает РФ полностью. - """ - try: - with httpx.Client(timeout=5) as c: - r = c.get( - "https://api.open-meteo.com/v1/forecast", - params={ - "latitude": lat, - "longitude": lon, - "daily": ( - "temperature_2m_max,temperature_2m_min," - "precipitation_sum,uv_index_max," - "winddirection_10m_dominant,windspeed_10m_max" - ), - "timezone": "Europe/Moscow", - "forecast_days": 7, - }, - ) - r.raise_for_status() - daily = r.json().get("daily", {}) - if not daily.get("time"): - return None - - t_max = daily.get("temperature_2m_max") or [] - t_min = daily.get("temperature_2m_min") or [] - precip = daily.get("precipitation_sum") or [] - uv = daily.get("uv_index_max") or [] - wind_d = daily.get("winddirection_10m_dominant") or [] - wind_s = daily.get("windspeed_10m_max") or [] - - # Circular mean направления ветра (vector sum) — избегает jump 359→1 - x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None) - y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None) - dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0 - rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"] - wind_label = rose[round(dominant / 45) % 8] - - return { - "forecast_days": len(daily.get("time", [])), - "temperature": { - "min_c": round(min(t_min), 1) if t_min else None, - "max_c": round(max(t_max), 1) if t_max else None, - "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None, - "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None, - }, - "precipitation_total_mm": round(sum(precip), 1) if precip else 0, - "precipitation_days": sum(1 for p in precip if p and p > 0.5), - "uv_index_max": round(max(uv), 1) if uv else None, - "wind": { - "dominant_direction_deg": round(dominant), - "dominant_direction_label": wind_label, - "max_speed_m_s": round(max(wind_s), 1) if wind_s else None, - }, - "source": "open-meteo", - "note": ( - "7-day forecast. Для исторических норм и B2B-данных — " - "Yandex Business / Gismeteo (платно)." - ), - } - except Exception as e: - logger.warning("weather fetch failed: %s", e) - return None - - # Координаты центра ЕКБ — Площадь 1905 года EKB_CENTER_LAT: float = 56.838011 EKB_CENTER_LON: float = 60.597474 @@ -1964,10 +1825,11 @@ def analyze_parcel( air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon) # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности) - weather = _fetch_weather_sync(centroid_lat, centroid_lon) + # PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py). + weather = get_weather_cached(centroid_lat, centroid_lon) - # 9b) Seasonal weather — 30-летние нормали Climate API - seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon) + # 9b) Seasonal weather — 30-летние нормали Climate API (TTL 7 суток). + seasonal_weather = get_seasonal_weather_cached(centroid_lat, centroid_lon) # 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb hydrology: dict[str, Any] | None = None diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py new file mode 100644 index 00000000..001f2524 --- /dev/null +++ b/backend/app/services/weather_cache.py @@ -0,0 +1,278 @@ +"""TTL-кэш для Open-Meteo (PR #1130 Phase A — Site Finder analyze latency). + +ПРОБЛЕМА: `analyze_parcel` в hot-path делал ДВА внешних HTTP-вызова к Open-Meteo на +каждый запрос (forecast + climate normals), без кэша. На проде backend в private network +с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay висит полный +timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfile показал 2.38с в hot-path +даже при частично успешном резолве. + +РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам. + + • Forecast (`get_weather_cached`) — 7-day погода, мало меняется в пределах часов. + TTL hit: 6 часов; TTL negative (failure): 5 минут — чтобы DNS-fail не повторял + `timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут. + • Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали, + обновляются ~раз в год. TTL hit: 7 суток; TTL negative: 5 минут. + +Ключ — `(round(lat, 2), round(lon, 2))`: округление до сотых ≈ 1 км, ЕКБ-wide один cad +≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от +утечки при потенциальном дрейфе ключей; для одного города этот лимит не достигается. + +КОРРЕКТНОСТЬ: контракт для caller'а не меняется — None по-прежнему допустим (caller в +`parcels.py:_build_environmental_score` обрабатывает `bool(weather)` как env_ok-флаг). +Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None. + +THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш +защищён своим `threading.Lock`. Single-flight: под lock'ом check-then-fetch-then-store, +поэтому конкурентный cold-start на ОДИН ключ делает ровно один сетевой вызов. + +time.monotonic используется вместо time.time — устойчиво к скачкам системного времени +(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки +времени за expires_at без манки самого time. +""" + +from __future__ import annotations + +import logging +import math +import threading +import time +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +# TTL в секундах (см. модульный docstring). +_WEATHER_TTL_S: float = 6 * 3600 # forecast — 6 часов +_SEASONAL_TTL_S: float = 7 * 86400 # climate normals — 7 суток +_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут оба кэша + +# Тайм-ауты httpx сокращены против оригинальных (5s/15s) — open-meteo обычно отвечает +# за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker +# 15с в hot-path. +_WEATHER_TIMEOUT_S: float = 2.0 +_SEASONAL_TIMEOUT_S: float = 3.0 + +# Soft-cap на размер каждой таблицы (округлённых координат для одного города мало). +_MAX_ENTRIES: int = 256 + +# (value | None, expires_at_monotonic_seconds). None в value = negative-cache. +_CacheEntry = tuple[dict[str, Any] | None, float] + +_FORECAST_CACHE: dict[tuple[float, float], _CacheEntry] = {} +_FORECAST_LOCK = threading.Lock() + +_CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {} +_CLIMATE_LOCK = threading.Lock() + + +def _now() -> float: + """Монотонный таймер (тесты подменяют этот helper, а не сам `time`).""" + return time.monotonic() + + +def _round_key(lat: float, lon: float) -> tuple[float, float]: + """Ключ кэша — координаты, округлённые до 0.01° (~1 км). См. docstring модуля.""" + return (round(lat, 2), round(lon, 2)) + + +def _evict_one_if_full(cache: dict[tuple[float, float], _CacheEntry]) -> None: + """Soft-cap eviction: при превышении `_MAX_ENTRIES` выбросить старейшую по вставке запись. + + Фактически FIFO благодаря insertion-order dict в CPython 3.7+: `next(iter(cache))` — + первый по порядку вставки ключ, т.е. самый старый (с большой вероятностью stale). + Не LRU — достаточно простой защиты от утечки на случай дрейфа ключей; LRU потребовал + бы `OrderedDict.move_to_end` на каждый hit (over-engineering при 256 lim + 6h TTL). + Под lock'ом вызывающей стороны. + """ + if len(cache) >= _MAX_ENTRIES: + victim = next(iter(cache)) + cache.pop(victim, None) + + +def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Forecast API (7-day) — собственно сетевой вызов и парсинг. + + Любое исключение (httpx, json, KeyError) → logger.warning + None (caller обернёт + None в negative-cache на короткий TTL). + """ + try: + with httpx.Client(timeout=_WEATHER_TIMEOUT_S) as c: + r = c.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": lat, + "longitude": lon, + "daily": ( + "temperature_2m_max,temperature_2m_min," + "precipitation_sum,uv_index_max," + "winddirection_10m_dominant,windspeed_10m_max" + ), + "timezone": "Europe/Moscow", + "forecast_days": 7, + }, + ) + r.raise_for_status() + daily = r.json().get("daily", {}) + if not daily.get("time"): + return None + + t_max = daily.get("temperature_2m_max") or [] + t_min = daily.get("temperature_2m_min") or [] + precip = daily.get("precipitation_sum") or [] + uv = daily.get("uv_index_max") or [] + wind_d = daily.get("winddirection_10m_dominant") or [] + wind_s = daily.get("windspeed_10m_max") or [] + + # Circular mean направления ветра (vector sum) — избегает jump 359→1 + x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None) + y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None) + dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0 + rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"] + wind_label = rose[round(dominant / 45) % 8] + + return { + "forecast_days": len(daily.get("time", [])), + "temperature": { + "min_c": round(min(t_min), 1) if t_min else None, + "max_c": round(max(t_max), 1) if t_max else None, + "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None, + "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None, + }, + "precipitation_total_mm": round(sum(precip), 1) if precip else 0, + "precipitation_days": sum(1 for p in precip if p and p > 0.5), + "uv_index_max": round(max(uv), 1) if uv else None, + "wind": { + "dominant_direction_deg": round(dominant), + "dominant_direction_label": wind_label, + "max_speed_m_s": round(max(wind_s), 1) if wind_s else None, + }, + "source": "open-meteo", + "note": ( + "7-day forecast. Для исторических норм и B2B-данных — " + "Yandex Business / Gismeteo (платно)." + ), + } + except Exception as e: + logger.warning("weather fetch failed: %s", e) + return None + + +def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Climate API — 30-летние сезонные нормали (MRI-AGCM3.2-S). + + Группирует 1995-2024 по четырём сезонам. Любое исключение → logger.warning + None + (caller обернёт None в negative-cache на короткий TTL). + """ + try: + with httpx.Client(timeout=_SEASONAL_TIMEOUT_S) as c: + r = c.get( + "https://climate-api.open-meteo.com/v1/climate", + params={ + "latitude": lat, + "longitude": lon, + "start_date": "1995-01-01", + "end_date": "2024-12-31", + "models": "MRI_AGCM3_2_S", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum", + }, + ) + r.raise_for_status() + data = r.json() + daily = data.get("daily", {}) + times = daily.get("time") or [] + t_max = daily.get("temperature_2m_max") or [] + t_min = daily.get("temperature_2m_min") or [] + precip = daily.get("precipitation_sum") or [] + if not times: + return None + + seasons_months = { + "winter": [12, 1, 2], + "spring": [3, 4, 5], + "summer": [6, 7, 8], + "autumn": [9, 10, 11], + } + buckets: dict[str, dict[str, list[float]]] = { + k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months + } + for i, t in enumerate(times): + month = int(t[5:7]) # 'YYYY-MM-DD' + for season, months in seasons_months.items(): + if month in months: + if i < len(t_max) and t_max[i] is not None: + buckets[season]["t_max"].append(t_max[i]) + if i < len(t_min) and t_min[i] is not None: + buckets[season]["t_min"].append(t_min[i]) + if i < len(precip) and precip[i] is not None: + buckets[season]["precip"].append(precip[i]) + break + + seasons: dict[str, Any] = {} + for season, vals in buckets.items(): + if not vals["t_max"]: + seasons[season] = None + continue + seasons[season] = { + "avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1), + "avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1), + "max_t_c": round(max(vals["t_max"]), 1), + "min_t_c": round(min(vals["t_min"]), 1), + "avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1), + "total_precip_mm": round(sum(vals["precip"]), 0), + "days_observed": len(vals["t_max"]), + } + return { + "seasons": seasons, + "period": "1995-2024 (30 лет)", + "model": "MRI-AGCM3-2-S", + "source": "open-meteo-climate", + "note": ("Климатические нормали. Текущая погода — отдельный API."), + } + except Exception as e: + logger.warning("seasonal weather fetch failed: %s", e) + return None + + +def get_weather_cached(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Forecast (7-day) с TTL-кэшем. + + Hit (успех): кэш 6 часов. Negative (None при ошибке): кэш 5 минут. Ключ — + координаты, округлённые до 0.01° (~1 км). На любой Exception от httpx/json + возвращает None и кэширует None под коротким TTL — контракт для caller'а не + меняется (None по-прежнему допустим). + """ + key = _round_key(lat, lon) + now = _now() + with _FORECAST_LOCK: + entry = _FORECAST_CACHE.get(key) + if entry is not None and entry[1] > now: + return entry[0] + # MISS или истёк → внутри lock'а делаем сетевой вызов (single-flight: 16 + # потоков на один ключ → один реальный запрос). + value = _fetch_weather_remote(lat, lon) + ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S + _evict_one_if_full(_FORECAST_CACHE) + _FORECAST_CACHE[key] = (value, _now() + ttl) + return value + + +def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Climate API (30-летние сезонные нормали) с TTL-кэшем. + + Hit (успех): кэш 7 суток (нормали меняются ~раз в год). Negative (None при + ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км). На любой + Exception от httpx/json возвращает None и кэширует None под коротким TTL. + """ + key = _round_key(lat, lon) + now = _now() + with _CLIMATE_LOCK: + entry = _CLIMATE_CACHE.get(key) + if entry is not None and entry[1] > now: + return entry[0] + value = _fetch_seasonal_remote(lat, lon) + ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S + _evict_one_if_full(_CLIMATE_CACHE) + _CLIMATE_CACHE[key] = (value, _now() + ttl) + return value diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index c6ff3768..510978c4 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -128,8 +128,8 @@ def _override_db(db: MagicMock): # чтобы тесты не зависели от сети и не требовали полного mock DB. _PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 456d256d..3043bbdd 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -127,8 +127,8 @@ def _override_db(db: MagicMock): _PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py index a3fda907..2b4b86ba 100644 --- a/backend/tests/api/v1/test_analyze_parcel_meta.py +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -131,8 +131,8 @@ def _override_db(db: MagicMock): _PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index f0ba3cd0..de13651f 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -116,8 +116,8 @@ def _override_db(db: MagicMock): _PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index bfd6e4c7..e212e341 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -326,8 +326,8 @@ def _override_db(db: MagicMock): _ANALYZE_PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index 019e069c..e446d2ce 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -96,8 +96,8 @@ def _override_db(db: MagicMock): # Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py). _PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/api/v1/test_run_history_and_response_contract.py b/backend/tests/api/v1/test_run_history_and_response_contract.py index f7019107..f2998abc 100644 --- a/backend/tests/api/v1/test_run_history_and_response_contract.py +++ b/backend/tests/api/v1/test_run_history_and_response_contract.py @@ -92,8 +92,8 @@ def _override_db(db: MagicMock): _HEAVY_PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), - patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), - patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch("app.api.v1.parcels.get_weather_cached", return_value=None), + patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py new file mode 100644 index 00000000..f4d9e91d --- /dev/null +++ b/backend/tests/services/test_weather_cache.py @@ -0,0 +1,340 @@ +"""Unit-тесты TTL-кэша Open-Meteo (PR #1130 Phase A — analyze latency 5-9s). + +Что покрываем (БЕЗ сети — httpx запатчен на уровне модуля): + + 1. HOT-CACHE HIT: два вызова с одинаковыми (lat, lon) → один сетевой запрос; третий + с округлённо-теми же координатами (56.838 и 56.839 оба → 56.84) → тоже hit, без + сетевого вызова. Сам смысл кэша — ради этого PR и сделан. + 2. NEGATIVE-CACHE: первый вызов бросает исключение → None; второй (в пределах + negative-TTL) → None БЕЗ сетевого вызова. Это правит главный продакшн-симптом + DNS-fail повторяет timeout на каждый analyze. + 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две + раздельные таблицы внутри модуля). + 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → + ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). + 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at + → следующий вызов идёт по сети заново (а не из устаревшего кэша). + +Каждый тест чистит обе кэш-таблицы в fixture'е (autouse) — между тестами нет утечки +состояния (модуль-singleton, dict'ы переживают между тестами). +""" + +from __future__ import annotations + +import os +import threading +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.services import weather_cache + +# ────────────────────────────────────────────────────────────────────────────── +# Фикстуры +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _reset_caches() -> Iterator[None]: + """Чистить обе TTL-таблицы до и после каждого теста (state живёт в модуле).""" + weather_cache._FORECAST_CACHE.clear() + weather_cache._CLIMATE_CACHE.clear() + yield + weather_cache._FORECAST_CACHE.clear() + weather_cache._CLIMATE_CACHE.clear() + + +def _make_forecast_response() -> dict[str, Any]: + """Минимальный валидный JSON от Open-Meteo Forecast API (7 дней).""" + return { + "daily": { + "time": [ + "2026-06-12", + "2026-06-13", + "2026-06-14", + "2026-06-15", + "2026-06-16", + "2026-06-17", + "2026-06-18", + ], + "temperature_2m_max": [20.0, 21.0, 22.0, 23.0, 22.0, 21.0, 20.0], + "temperature_2m_min": [10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 10.0], + "precipitation_sum": [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], + "uv_index_max": [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0], + "winddirection_10m_dominant": [180, 180, 180, 180, 180, 180, 180], + "windspeed_10m_max": [3.0, 4.0, 3.0, 4.0, 3.0, 4.0, 3.0], + } + } + + +def _make_climate_response() -> dict[str, Any]: + """Минимальный валидный JSON от Open-Meteo Climate API — пара дат каждого сезона.""" + return { + "daily": { + "time": [ + "2000-01-15", + "2000-04-15", + "2000-07-15", + "2000-10-15", + ], + "temperature_2m_max": [-5.0, 10.0, 25.0, 5.0], + "temperature_2m_min": [-15.0, 0.0, 15.0, -5.0], + "precipitation_sum": [2.0, 3.0, 4.0, 5.0], + } + } + + +def _make_httpx_response(payload: dict[str, Any]) -> MagicMock: + """MagicMock в форме httpx.Response: `.raise_for_status()` no-op, `.json()` → payload.""" + resp = MagicMock() + resp.raise_for_status = MagicMock(return_value=None) + resp.json = MagicMock(return_value=payload) + return resp + + +# ────────────────────────────────────────────────────────────────────────────── +# 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы). +# ────────────────────────────────────────────────────────────────────────────── + + +class TestHotCacheHit: + def test_same_coords_skip_network_after_first_call(self) -> None: + """Два вызова с одинаковыми (lat, lon) → один сетевой запрос.""" + get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + r1 = weather_cache.get_weather_cached(56.84, 60.59) + r2 = weather_cache.get_weather_cached(56.84, 60.59) + assert r1 is not None + assert r2 == r1 # тот же payload (из кэша) + assert get.call_count == 1 # сетевой вызов ОДИН на оба запроса + + def test_close_coords_round_to_same_key(self) -> None: + """56.838 и 56.839 (обе → round 56.84) дают тот же ключ кэша → 1 сетевой вызов.""" + get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + weather_cache.get_weather_cached(56.838, 60.597) + weather_cache.get_weather_cached(56.839, 60.598) + weather_cache.get_weather_cached(56.841, 60.594) + # Все три round до (56.84, 60.60)? Проверим явно. + # round(56.838,2)=56.84, round(56.839,2)=56.84, round(56.841,2)=56.84. + # round(60.597,2)=60.60, round(60.598,2)=60.60, round(60.594,2)=60.59. + # Значит первый и второй — один ключ, третий — другой. + # Значит ожидаем 2 сетевых вызова (а не 3, не 1). + assert get.call_count == 2 + + def test_distant_coords_use_distinct_keys(self) -> None: + """56.84 и 56.90 (round до разных сотых) → отдельные слоты кэша → 2 вызова.""" + get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + weather_cache.get_weather_cached(56.84, 60.59) + weather_cache.get_weather_cached(56.90, 60.59) + assert get.call_count == 2 + + +# ────────────────────────────────────────────────────────────────────────────── +# 2. Negative-cache (главный продакшн-симптом #1130 — DNS-fail → timeout на каждый +# analyze). +# ────────────────────────────────────────────────────────────────────────────── + + +class TestNegativeCacheOnFailure: + def test_failure_caches_none_and_skips_retry(self) -> None: + """Первый вызов → исключение → None; второй (в пределах TTL) → None без сети.""" + get = MagicMock(side_effect=RuntimeError("simulated DNS failure")) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + r1 = weather_cache.get_weather_cached(56.84, 60.59) + r2 = weather_cache.get_weather_cached(56.84, 60.59) + assert r1 is None + assert r2 is None + assert get.call_count == 1 # ровно ОДНА попытка → негативный кэш сработал + + def test_empty_daily_caches_none(self) -> None: + """Open-Meteo вернул валидный JSON, но `daily.time` пуст → None кэшируется.""" + get = MagicMock(return_value=_make_httpx_response({"daily": {"time": []}})) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + r1 = weather_cache.get_weather_cached(56.84, 60.59) + r2 = weather_cache.get_weather_cached(56.84, 60.59) + assert r1 is None + assert r2 is None + assert get.call_count == 1 + + def test_seasonal_failure_caches_none(self) -> None: + """Симметрично для climate-API — failure → negative-cache, второй вызов из кэша.""" + get = MagicMock(side_effect=RuntimeError("simulated DNS failure")) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + r1 = weather_cache.get_seasonal_weather_cached(56.84, 60.59) + r2 = weather_cache.get_seasonal_weather_cached(56.84, 60.59) + assert r1 is None + assert r2 is None + assert get.call_count == 1 + + +# ────────────────────────────────────────────────────────────────────────────── +# 3. Изоляция forecast- и climate-кэшей. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestSeparateCachesForForecastAndClimate: + def test_forecast_call_does_not_populate_climate_cache(self) -> None: + """forecast-кэш и climate-кэш живут отдельно — записи одного не видны другому.""" + # Отдельный счётчик GET'ов для двух подряд вызовов разных функций. + get = MagicMock( + side_effect=[ + _make_httpx_response(_make_forecast_response()), + _make_httpx_response(_make_climate_response()), + ] + ) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + forecast = weather_cache.get_weather_cached(56.84, 60.59) + seasonal = weather_cache.get_seasonal_weather_cached(56.84, 60.59) + assert forecast is not None + assert seasonal is not None + # forecast-вызов оставил запись в _FORECAST_CACHE, climate — в _CLIMATE_CACHE. + assert (56.84, 60.59) in weather_cache._FORECAST_CACHE + assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE + # ИЗОЛЯЦИЯ: forecast НЕ попал в climate-таблицу (раздельные dict'ы). + # Сетевых вызовов было ровно 2 (по одному на каждую функцию — не схлопнулись). + assert get.call_count == 2 + + +# ────────────────────────────────────────────────────────────────────────────── +# 4. Single-flight под конкурентностью (16 потоков → 1 сетевой вызов). +# ────────────────────────────────────────────────────────────────────────────── + + +class TestConcurrencySafe: + def test_single_flight_cold_start_one_network_call(self) -> None: + """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" + # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. + start_barrier = threading.Barrier(16) + get_call_count = 0 + get_lock = threading.Lock() + + def _slow_get(*args: Any, **kwargs: Any) -> Any: + nonlocal get_call_count + with get_lock: + get_call_count += 1 + # Микро-задержка — окно для других потоков добраться до lock'а. + # Не делаем sleep большим, чтобы тест не висел. + return _make_httpx_response(_make_forecast_response()) + + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=_slow_get)) + client_ctx.__exit__ = MagicMock(return_value=None) + + results: list[dict[str, Any] | None] = [] + results_lock = threading.Lock() + + def _worker() -> None: + start_barrier.wait() + value = weather_cache.get_weather_cached(56.84, 60.59) + with results_lock: + results.append(value) + + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + threads = [threading.Thread(target=_worker) for _ in range(16)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(results) == 16 + assert all(r is not None for r in results) + # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. + assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" + + +# ────────────────────────────────────────────────────────────────────────────── +# 5. Истечение TTL — после прохождения времени за expires_at новый сетевой вызов. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestExpiresAfterTtl: + def test_forecast_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Проталкиваем `_now` за hot-TTL → следующий вызов идёт по сети заново.""" + get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + + clock = [1000.0] + + def _fake_now() -> float: + return clock[0] + + monkeypatch.setattr(weather_cache, "_now", _fake_now) + + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + weather_cache.get_weather_cached(56.84, 60.59) + assert get.call_count == 1 + # Проталкиваем время за hot-TTL (6h + запас). + clock[0] += weather_cache._WEATHER_TTL_S + 1 + weather_cache.get_weather_cached(56.84, 60.59) + assert get.call_count == 2 + + def test_negative_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: + """После negative-TTL (5 мин) повторный вызов снова бьёт по сети.""" + get = MagicMock(side_effect=RuntimeError("DNS")) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + + clock = [1000.0] + + def _fake_now() -> float: + return clock[0] + + monkeypatch.setattr(weather_cache, "_now", _fake_now) + + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + assert weather_cache.get_weather_cached(56.84, 60.59) is None + assert get.call_count == 1 + clock[0] += weather_cache._NEGATIVE_TTL_S + 1 + assert weather_cache.get_weather_cached(56.84, 60.59) is None + assert get.call_count == 2 # ретрай после истечения negative-TTL + + def test_seasonal_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Climate normals — после 7 суток ретрай по сети.""" + get = MagicMock(return_value=_make_httpx_response(_make_climate_response())) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + + clock = [1000.0] + + def _fake_now() -> float: + return clock[0] + + monkeypatch.setattr(weather_cache, "_now", _fake_now) + + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + weather_cache.get_seasonal_weather_cached(56.84, 60.59) + assert get.call_count == 1 + clock[0] += weather_cache._SEASONAL_TTL_S + 1 + weather_cache.get_seasonal_weather_cached(56.84, 60.59) + assert get.call_count == 2 -- 2.45.3