gendesign/backend/tests/services/test_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

340 lines
19 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.

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