perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A) #1194

Merged
bot-backend merged 1 commit from perf/analyze-weather-ttl-cache into main 2026-06-12 16:14:10 +00:00
10 changed files with 640 additions and 160 deletions

View file

@ -87,6 +87,10 @@ from app.services.site_finder.weight_profiles import (
from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import (
resolve_weights as _resolve_weights, resolve_weights as _resolve_weights,
) )
from app.services.weather_cache import (
get_seasonal_weather_cached,
get_weather_cached,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -147,149 +151,6 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
return 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 года # Координаты центра ЕКБ — Площадь 1905 года
EKB_CENTER_LAT: float = 56.838011 EKB_CENTER_LAT: float = 56.838011
EKB_CENTER_LON: float = 60.597474 EKB_CENTER_LON: float = 60.597474
@ -1964,10 +1825,11 @@ def analyze_parcel(
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon) air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности) # 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 # 9b) Seasonal weather — 30-летние нормали Climate API (TTL 7 суток).
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon) seasonal_weather = get_seasonal_weather_cached(centroid_lat, centroid_lon)
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb # 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
hydrology: dict[str, Any] | None = None hydrology: dict[str, Any] | None = None

View file

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

View file

@ -128,8 +128,8 @@ def _override_db(db: MagicMock):
# чтобы тесты не зависели от сети и не требовали полного mock DB. # чтобы тесты не зависели от сети и не требовали полного mock DB.
_PATCHES = [ _PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -127,8 +127,8 @@ def _override_db(db: MagicMock):
_PATCHES = [ _PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -131,8 +131,8 @@ def _override_db(db: MagicMock):
_PATCHES = [ _PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -116,8 +116,8 @@ def _override_db(db: MagicMock):
_PATCHES = [ _PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -326,8 +326,8 @@ def _override_db(db: MagicMock):
_ANALYZE_PATCHES = [ _ANALYZE_PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -96,8 +96,8 @@ def _override_db(db: MagicMock):
# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py). # Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
_PATCHES = [ _PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

@ -92,8 +92,8 @@ def _override_db(db: MagicMock):
_HEAVY_PATCHES = [ _HEAVY_PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), 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.get_weather_cached", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch( patch(
"app.api.v1.parcels.get_quarter_dump_data", "app.api.v1.parcels.get_quarter_dump_data",
return_value={ return_value={

View file

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