perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A) #1194
10 changed files with 640 additions and 160 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
278
backend/app/services/weather_cache.py
Normal file
278
backend/app/services/weather_cache.py
Normal 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
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
340
backend/tests/services/test_weather_cache.py
Normal file
340
backend/tests/services/test_weather_cache.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue