gendesign/backend/app/services/weather_cache.py
Light1YT 49b85ab1d6
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
CI / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m24s
perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B)
Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194):

1. get_air_quality_cached в weather_cache (третий TTL-слот рядом с forecast/climate):
   - hit-TTL 1h (hourly bucket Open-Meteo), negative-TTL 5min.
   - timeout 5s → 2s.
   - single-flight под per-cache threading.Lock.
   - Снимает 0.26с на cache-hit и до 5с при DNS-fail (тот же антипаттерн,
     что был у forecast/climate до Phase A).

2. _neighbors_summary: 2 SQL → 1 statement.
   - WITH neighbors AS (... LIMIT 30), overlaps AS (... LIMIT 5)
   - + COALESCE(json_agg(row_to_json(...)), '[]'::json) в основном SELECT.
   - Один round-trip вместо двух (~-47 ms на каждый analyze).
   - Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors,
     ST_Intersects + ST_Area для overlaps. Anti-fakes guards (_COST_PER_M2_MIN/MAX,
     float() try/except, >50 m² overlap) сохранены.

Формат возвращаемых dict не меняется (frontend контракт). 5 новых юнит-тестов в
test_weather_cache.py (TestAirQualityCache: hot-hit, negative, empty-hourly,
separate-from-weather, ttl-expires). 7 файлов API-тестов обновили patch-цель
_fetch_air_quality_sync → get_air_quality_cached.

Refs #1130
2026-06-12 22:42:32 +05:00

342 lines
17 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+B — Site Finder analyze latency).
ПРОБЛЕМА: `analyze_parcel` в hot-path делал ТРИ внешних HTTP-вызова к Open-Meteo на
каждый запрос (forecast + climate normals + air-quality), без кэша. На проде backend в
private network с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay
висит полный timeout (5s + 15s + 5s = до 25с лишних на analyze). Профиль cProfile показал
~0.26с air-quality в успешном hit и +5с при DNS-fail (тот же антипаттерн, что был у
forecast/climate до Phase A).
РЕШЕНИЕ: 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 минут.
• Air quality (`get_air_quality_cached`) — текущий час pm2_5/pm10/no2.
TTL hit: 1 час (фронт-API API hourly bucket, обновлять чаще forecast);
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 суток
_AIR_TTL_S: float = 1 * 3600 # air quality — 1 час (текущий час, обновлять чаще forecast)
_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут все кэши
# Тайм-ауты httpx сокращены против оригинальных (5s/15s/5s) — open-meteo обычно отвечает
# за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker
# 15с в hot-path.
_WEATHER_TIMEOUT_S: float = 2.0
_SEASONAL_TIMEOUT_S: float = 3.0
_AIR_TIMEOUT_S: float = 2.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()
_AIR_CACHE: dict[tuple[float, float], _CacheEntry] = {}
_AIR_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
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
Перенесено из `app.api.v1.parcels._fetch_air_quality_sync` без изменения формата
возвращаемого dict (фронт зависит). Любое исключение → logger.warning + None
(caller обернёт None в negative-cache на короткий TTL).
"""
try:
with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
r = c.get(
"https://air-quality-api.open-meteo.com/v1/air-quality",
params={
"latitude": lat,
"longitude": lon,
"hourly": "pm2_5,pm10,nitrogen_dioxide",
"forecast_days": 1,
},
)
r.raise_for_status()
data = r.json()
hourly = data.get("hourly", {})
if not hourly.get("time"):
return None
return {
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
"ts": hourly["time"][0],
"source": "open-meteo",
}
except Exception as e:
logger.warning("air quality fetch failed: %s", e)
return None
def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None:
"""Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа) с TTL-кэшем.
Hit (успех): кэш 1 час (hourly bucket API — обновлять чаще forecast). Negative
(None при ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км).
На любой Exception от httpx/json возвращает None и кэширует None под коротким TTL.
"""
key = _round_key(lat, lon)
now = _now()
with _AIR_LOCK:
entry = _AIR_CACHE.get(key)
if entry is not None and entry[1] > now:
return entry[0]
value = _fetch_air_quality_remote(lat, lon)
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
_evict_one_if_full(_AIR_CACHE)
_AIR_CACHE[key] = (value, _now() + ttl)
return value