perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B)
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
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
This commit is contained in:
parent
97ff0c9e81
commit
49b85ab1d6
10 changed files with 253 additions and 102 deletions
|
|
@ -88,6 +88,7 @@ from app.services.site_finder.weight_profiles import (
|
|||
resolve_weights as _resolve_weights,
|
||||
)
|
||||
from app.services.weather_cache import (
|
||||
get_air_quality_cached,
|
||||
get_seasonal_weather_cached,
|
||||
get_weather_cached,
|
||||
)
|
||||
|
|
@ -117,40 +118,6 @@ def _wind_label(deg: float) -> str:
|
|||
return rose[idx]
|
||||
|
||||
|
||||
def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
|
||||
"""Синхронный запрос к Open-Meteo Air Quality API.
|
||||
|
||||
Возвращает данные текущего часа (первый элемент hourly). None если API
|
||||
недоступен или вернул неожиданный формат.
|
||||
"""
|
||||
try:
|
||||
with httpx.Client(timeout=5) 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
|
||||
|
||||
|
||||
# Координаты центра ЕКБ — Площадь 1905 года
|
||||
EKB_CENTER_LAT: float = 56.838011
|
||||
EKB_CENTER_LON: float = 60.597474
|
||||
|
|
@ -621,37 +588,74 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
список соседей для UI + флаг has_existing_buildings (overlap >50 м²).
|
||||
|
||||
Использует GIST на cad_buildings.geom (уже создан в schema).
|
||||
|
||||
PR #1130 Phase B: соседи (LIMIT 30) и overlap-check (LIMIT 5) объединены в
|
||||
ОДИН SQL-statement через две CTE + scalar-агрегацию в `json_agg`. Это срезает
|
||||
один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не
|
||||
меняются. Формат возвращаемого dict идентичен прежнему.
|
||||
"""
|
||||
try:
|
||||
neighbor_rows = (
|
||||
row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
floors,
|
||||
year_built,
|
||||
cost_value,
|
||||
area,
|
||||
readable_address,
|
||||
ST_Distance(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography
|
||||
) AS distance_m
|
||||
FROM cad_buildings b
|
||||
WHERE ST_DWithin(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
100
|
||||
)
|
||||
AND b.cad_num != :our_cad
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 30
|
||||
WITH neighbors AS (
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
floors,
|
||||
year_built,
|
||||
cost_value,
|
||||
area,
|
||||
readable_address,
|
||||
ST_Distance(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography
|
||||
) AS distance_m
|
||||
FROM cad_buildings b
|
||||
WHERE ST_DWithin(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
100
|
||||
)
|
||||
AND b.cad_num != :our_cad
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 30
|
||||
),
|
||||
overlaps AS (
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
floors,
|
||||
readable_address,
|
||||
ST_Area(
|
||||
ST_Intersection(
|
||||
ST_Transform(b.geom, 32641),
|
||||
ST_Transform(ST_GeomFromText(:wkt, 4326), 32641)
|
||||
)
|
||||
) AS overlap_m2
|
||||
FROM cad_buildings b
|
||||
WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326))
|
||||
AND b.cad_num != :our_cad
|
||||
ORDER BY overlap_m2 DESC NULLS LAST
|
||||
LIMIT 5
|
||||
)
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC)
|
||||
FROM neighbors n),
|
||||
'[]'::json
|
||||
) AS neighbors,
|
||||
COALESCE(
|
||||
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
|
||||
FROM overlaps o),
|
||||
'[]'::json
|
||||
) AS overlaps
|
||||
"""),
|
||||
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
.first()
|
||||
)
|
||||
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
|
||||
overlap_row: list[dict[str, Any]] = list(row["overlaps"]) if row else []
|
||||
except Exception as e:
|
||||
logger.warning("neighbors query failed: %s", e)
|
||||
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||||
|
|
@ -683,35 +687,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
|
||||
# Overlap check — что-то построено непосредственно на нашем участке.
|
||||
# Если хоть один building пересекается с площадью >50 м² — hard warn.
|
||||
try:
|
||||
overlap_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
floors,
|
||||
readable_address,
|
||||
ST_Area(
|
||||
ST_Intersection(
|
||||
ST_Transform(b.geom, 32641),
|
||||
ST_Transform(ST_GeomFromText(:wkt, 4326), 32641)
|
||||
)
|
||||
) AS overlap_m2
|
||||
FROM cad_buildings b
|
||||
WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326))
|
||||
AND b.cad_num != :our_cad
|
||||
ORDER BY overlap_m2 DESC NULLS LAST
|
||||
LIMIT 5
|
||||
"""),
|
||||
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("overlap check failed: %s", e)
|
||||
overlap_row = []
|
||||
|
||||
overlap_buildings = [
|
||||
{
|
||||
"cad_num": o["cad_num"],
|
||||
|
|
@ -1822,7 +1797,8 @@ def analyze_parcel(
|
|||
noise_level = "шумно"
|
||||
|
||||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
||||
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
||||
# PR #1130 Phase B: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
|
||||
air_q = get_air_quality_cached(centroid_lat, centroid_lon)
|
||||
|
||||
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||||
# PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""TTL-кэш для Open-Meteo (PR #1130 Phase A — Site Finder analyze latency).
|
||||
"""TTL-кэш для Open-Meteo (PR #1130 Phase A+B — 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
|
||||
даже при частично успешном резолве.
|
||||
ПРОБЛЕМА: `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 км координатам.
|
||||
|
||||
|
|
@ -13,6 +14,9 @@ timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfil
|
|||
`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 первого попавшегося) — защита от
|
||||
|
|
@ -46,13 +50,15 @@ 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 минут оба кэша
|
||||
_AIR_TTL_S: float = 1 * 3600 # air quality — 1 час (текущий час, обновлять чаще forecast)
|
||||
_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут все кэши
|
||||
|
||||
# Тайм-ауты httpx сокращены против оригинальных (5s/15s) — open-meteo обычно отвечает
|
||||
# Тайм-ауты 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
|
||||
|
|
@ -66,6 +72,9 @@ _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`)."""
|
||||
|
|
@ -276,3 +285,58 @@ def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None
|
|||
_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
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ def _override_db(db: MagicMock):
|
|||
# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump),
|
||||
# чтобы тесты не зависели от сети и не требовали полного mock DB.
|
||||
_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ def _override_db(db: MagicMock):
|
|||
|
||||
|
||||
_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def _override_db(db: MagicMock):
|
|||
|
||||
|
||||
_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ def _override_db(db: MagicMock):
|
|||
|
||||
|
||||
_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ def _override_db(db: MagicMock):
|
|||
|
||||
|
||||
_ANALYZE_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ 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.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def _override_db(db: MagicMock):
|
|||
|
||||
|
||||
_HEAVY_PATCHES = [
|
||||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", 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(
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@ from app.services import weather_cache
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_caches() -> Iterator[None]:
|
||||
"""Чистить обе TTL-таблицы до и после каждого теста (state живёт в модуле)."""
|
||||
"""Чистить все TTL-таблицы до и после каждого теста (state живёт в модуле)."""
|
||||
weather_cache._FORECAST_CACHE.clear()
|
||||
weather_cache._CLIMATE_CACHE.clear()
|
||||
weather_cache._AIR_CACHE.clear()
|
||||
yield
|
||||
weather_cache._FORECAST_CACHE.clear()
|
||||
weather_cache._CLIMATE_CACHE.clear()
|
||||
weather_cache._AIR_CACHE.clear()
|
||||
|
||||
|
||||
def _make_forecast_response() -> dict[str, Any]:
|
||||
|
|
@ -96,6 +98,18 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
|
|||
return resp
|
||||
|
||||
|
||||
def _make_air_response() -> dict[str, Any]:
|
||||
"""Минимальный валидный JSON от Open-Meteo Air Quality API (24 часа forecast)."""
|
||||
return {
|
||||
"hourly": {
|
||||
"time": ["2026-06-12T00:00", "2026-06-12T01:00"],
|
||||
"pm2_5": [12.5, 13.0],
|
||||
"pm10": [25.0, 26.0],
|
||||
"nitrogen_dioxide": [15.0, 16.0],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -338,3 +352,100 @@ class TestExpiresAfterTtl:
|
|||
clock[0] += weather_cache._SEASONAL_TTL_S + 1
|
||||
weather_cache.get_seasonal_weather_cached(56.84, 60.59)
|
||||
assert get.call_count == 2
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 6. Air-quality cache (PR #1130 Phase B). Зеркало паттернов forecast/climate.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAirQualityCache:
|
||||
def test_air_hot_cache_hit_skips_network(self) -> None:
|
||||
"""Два вызова с одинаковыми (lat, lon) → ровно один сетевой запрос."""
|
||||
get = MagicMock(return_value=_make_httpx_response(_make_air_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_air_quality_cached(56.84, 60.59)
|
||||
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
|
||||
assert r1 is not None
|
||||
assert r2 == r1
|
||||
# Контракт для caller'а: формат dict не сменился относительно прежнего
|
||||
# `_fetch_air_quality_sync` — фронт зависит от этих ключей.
|
||||
assert set(r1.keys()) == {"pm2_5", "pm10", "no2", "ts", "source"}
|
||||
assert r1["source"] == "open-meteo"
|
||||
assert get.call_count == 1
|
||||
|
||||
def test_air_negative_cache_on_failure(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_air_quality_cached(56.84, 60.59)
|
||||
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
|
||||
assert r1 is None
|
||||
assert r2 is None
|
||||
assert get.call_count == 1 # ровно одна попытка → negative cache сработал
|
||||
|
||||
def test_air_empty_hourly_caches_none(self) -> None:
|
||||
"""Open-Meteo вернул валидный JSON, но `hourly.time` пуст → None кэшируется."""
|
||||
get = MagicMock(return_value=_make_httpx_response({"hourly": {"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_air_quality_cached(56.84, 60.59)
|
||||
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
|
||||
assert r1 is None
|
||||
assert r2 is None
|
||||
assert get.call_count == 1
|
||||
|
||||
def test_air_separate_from_weather(self) -> None:
|
||||
"""Air cache не отравляет weather/climate cache и наоборот (три раздельных dict'а)."""
|
||||
# Три последовательных вызова разных функций — каждый делает свой сетевой call.
|
||||
get = MagicMock(
|
||||
side_effect=[
|
||||
_make_httpx_response(_make_forecast_response()),
|
||||
_make_httpx_response(_make_air_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):
|
||||
weather = weather_cache.get_weather_cached(56.84, 60.59)
|
||||
air = weather_cache.get_air_quality_cached(56.84, 60.59)
|
||||
climate = weather_cache.get_seasonal_weather_cached(56.84, 60.59)
|
||||
assert weather is not None
|
||||
assert air is not None
|
||||
assert climate is not None
|
||||
# Каждый кэш живёт в своём dict — записи не смешиваются.
|
||||
assert (56.84, 60.59) in weather_cache._FORECAST_CACHE
|
||||
assert (56.84, 60.59) in weather_cache._AIR_CACHE
|
||||
assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE
|
||||
assert get.call_count == 3 # три раздельных сетевых вызова
|
||||
|
||||
def test_air_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Через `_AIR_TTL_S` (1 час) повторный вызов снова бьёт по сети."""
|
||||
get = MagicMock(return_value=_make_httpx_response(_make_air_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_air_quality_cached(56.84, 60.59)
|
||||
assert get.call_count == 1
|
||||
clock[0] += weather_cache._AIR_TTL_S + 1
|
||||
weather_cache.get_air_quality_cached(56.84, 60.59)
|
||||
assert get.call_count == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue