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
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
342 lines
17 KiB
Python
342 lines
17 KiB
Python
"""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
|