gendesign/backend/app/services/weather_cache.py
Light1YT 97ff0c9e81
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m26s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 4m51s
Deploy / deploy (push) Successful in 1m24s
perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A)
Два внешних 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
2026-06-12 19:58:07 +05:00

278 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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