perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A)
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
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
Два внешних 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
This commit is contained in:
parent
d64a57cfcd
commit
97ff0c9e81
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 (
|
||||
resolve_weights as _resolve_weights,
|
||||
)
|
||||
from app.services.weather_cache import (
|
||||
get_seasonal_weather_cached,
|
||||
get_weather_cached,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -147,149 +151,6 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | 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 года
|
||||
EKB_CENTER_LAT: float = 56.838011
|
||||
EKB_CENTER_LON: float = 60.597474
|
||||
|
|
@ -1964,10 +1825,11 @@ def analyze_parcel(
|
|||
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
||||
|
||||
# 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
|
||||
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)
|
||||
# 9b) Seasonal weather — 30-летние нормали Climate API (TTL 7 суток).
|
||||
seasonal_weather = get_seasonal_weather_cached(centroid_lat, centroid_lon)
|
||||
|
||||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||||
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.
|
||||
_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ def _override_db(db: MagicMock):
|
|||
|
||||
_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ def _override_db(db: MagicMock):
|
|||
|
||||
_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ def _override_db(db: MagicMock):
|
|||
|
||||
_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -326,8 +326,8 @@ def _override_db(db: MagicMock):
|
|||
|
||||
_ANALYZE_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ def _override_db(db: MagicMock):
|
|||
# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
|
||||
_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ def _override_db(db: MagicMock):
|
|||
|
||||
_HEAVY_PATCHES = [
|
||||
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._fetch_seasonal_weather_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
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