perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B) #1195
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,
|
resolve_weights as _resolve_weights,
|
||||||
)
|
)
|
||||||
from app.services.weather_cache import (
|
from app.services.weather_cache import (
|
||||||
|
get_air_quality_cached,
|
||||||
get_seasonal_weather_cached,
|
get_seasonal_weather_cached,
|
||||||
get_weather_cached,
|
get_weather_cached,
|
||||||
)
|
)
|
||||||
|
|
@ -117,40 +118,6 @@ def _wind_label(deg: float) -> str:
|
||||||
return rose[idx]
|
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 года
|
# Координаты центра ЕКБ — Площадь 1905 года
|
||||||
EKB_CENTER_LAT: float = 56.838011
|
EKB_CENTER_LAT: float = 56.838011
|
||||||
EKB_CENTER_LON: float = 60.597474
|
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 м²).
|
список соседей для UI + флаг has_existing_buildings (overlap >50 м²).
|
||||||
|
|
||||||
Использует GIST на cad_buildings.geom (уже создан в schema).
|
Использует 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:
|
try:
|
||||||
neighbor_rows = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT cad_num,
|
WITH neighbors AS (
|
||||||
building_name,
|
SELECT cad_num,
|
||||||
floors,
|
building_name,
|
||||||
year_built,
|
floors,
|
||||||
cost_value,
|
year_built,
|
||||||
area,
|
cost_value,
|
||||||
readable_address,
|
area,
|
||||||
ST_Distance(
|
readable_address,
|
||||||
b.geom::geography,
|
ST_Distance(
|
||||||
ST_GeomFromText(:wkt, 4326)::geography
|
b.geom::geography,
|
||||||
) AS distance_m
|
ST_GeomFromText(:wkt, 4326)::geography
|
||||||
FROM cad_buildings b
|
) AS distance_m
|
||||||
WHERE ST_DWithin(
|
FROM cad_buildings b
|
||||||
b.geom::geography,
|
WHERE ST_DWithin(
|
||||||
ST_GeomFromText(:wkt, 4326)::geography,
|
b.geom::geography,
|
||||||
100
|
ST_GeomFromText(:wkt, 4326)::geography,
|
||||||
)
|
100
|
||||||
AND b.cad_num != :our_cad
|
)
|
||||||
ORDER BY distance_m ASC
|
AND b.cad_num != :our_cad
|
||||||
LIMIT 30
|
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},
|
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||||||
)
|
)
|
||||||
.mappings()
|
.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:
|
except Exception as e:
|
||||||
logger.warning("neighbors query failed: %s", e)
|
logger.warning("neighbors query failed: %s", e)
|
||||||
return {"data_available": False, "note": f"neighbors query failed: {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 — что-то построено непосредственно на нашем участке.
|
# Overlap check — что-то построено непосредственно на нашем участке.
|
||||||
# Если хоть один building пересекается с площадью >50 м² — hard warn.
|
# Если хоть один 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 = [
|
overlap_buildings = [
|
||||||
{
|
{
|
||||||
"cad_num": o["cad_num"],
|
"cad_num": o["cad_num"],
|
||||||
|
|
@ -1822,7 +1797,8 @@ def analyze_parcel(
|
||||||
noise_level = "шумно"
|
noise_level = "шумно"
|
||||||
|
|
||||||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
# 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 при недоступности)
|
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||||||
# PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
|
# 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 на
|
ПРОБЛЕМА: `analyze_parcel` в hot-path делал ТРИ внешних HTTP-вызова к Open-Meteo на
|
||||||
каждый запрос (forecast + climate normals), без кэша. На проде backend в private network
|
каждый запрос (forecast + climate normals + air-quality), без кэша. На проде backend в
|
||||||
с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay висит полный
|
private network с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay
|
||||||
timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfile показал 2.38с в hot-path
|
висит полный timeout (5s + 15s + 5s = до 25с лишних на analyze). Профиль cProfile показал
|
||||||
даже при частично успешном резолве.
|
~0.26с air-quality в успешном hit и +5с при DNS-fail (тот же антипаттерн, что был у
|
||||||
|
forecast/climate до Phase A).
|
||||||
|
|
||||||
РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам.
|
РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам.
|
||||||
|
|
||||||
|
|
@ -13,6 +14,9 @@ timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfil
|
||||||
`timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут.
|
`timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут.
|
||||||
• Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали,
|
• Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали,
|
||||||
обновляются ~раз в год. TTL hit: 7 суток; TTL negative: 5 минут.
|
обновляются ~раз в год. 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
|
Ключ — `(round(lat, 2), round(lon, 2))`: округление до сотых ≈ 1 км, ЕКБ-wide один cad
|
||||||
≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от
|
≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от
|
||||||
|
|
@ -46,13 +50,15 @@ logger = logging.getLogger(__name__)
|
||||||
# TTL в секундах (см. модульный docstring).
|
# TTL в секундах (см. модульный docstring).
|
||||||
_WEATHER_TTL_S: float = 6 * 3600 # forecast — 6 часов
|
_WEATHER_TTL_S: float = 6 * 3600 # forecast — 6 часов
|
||||||
_SEASONAL_TTL_S: float = 7 * 86400 # climate normals — 7 суток
|
_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
|
# за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker
|
||||||
# 15с в hot-path.
|
# 15с в hot-path.
|
||||||
_WEATHER_TIMEOUT_S: float = 2.0
|
_WEATHER_TIMEOUT_S: float = 2.0
|
||||||
_SEASONAL_TIMEOUT_S: float = 3.0
|
_SEASONAL_TIMEOUT_S: float = 3.0
|
||||||
|
_AIR_TIMEOUT_S: float = 2.0
|
||||||
|
|
||||||
# Soft-cap на размер каждой таблицы (округлённых координат для одного города мало).
|
# Soft-cap на размер каждой таблицы (округлённых координат для одного города мало).
|
||||||
_MAX_ENTRIES: int = 256
|
_MAX_ENTRIES: int = 256
|
||||||
|
|
@ -66,6 +72,9 @@ _FORECAST_LOCK = threading.Lock()
|
||||||
_CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
_CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
||||||
_CLIMATE_LOCK = threading.Lock()
|
_CLIMATE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
_AIR_CACHE: dict[tuple[float, float], _CacheEntry] = {}
|
||||||
|
_AIR_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> float:
|
def _now() -> float:
|
||||||
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
|
"""Монотонный таймер (тесты подменяют этот 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)
|
_evict_one_if_full(_CLIMATE_CACHE)
|
||||||
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
||||||
return value
|
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),
|
# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump),
|
||||||
# чтобы тесты не зависели от сети и не требовали полного mock DB.
|
# чтобы тесты не зависели от сети и не требовали полного mock DB.
|
||||||
_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
|
|
||||||
_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
|
|
||||||
_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
|
|
||||||
_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
|
|
||||||
_ANALYZE_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
|
# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
|
||||||
_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ def _override_db(db: MagicMock):
|
||||||
|
|
||||||
|
|
||||||
_HEAVY_PATCHES = [
|
_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_weather_cached", return_value=None),
|
||||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,14 @@ from app.services import weather_cache
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_caches() -> Iterator[None]:
|
def _reset_caches() -> Iterator[None]:
|
||||||
"""Чистить обе TTL-таблицы до и после каждого теста (state живёт в модуле)."""
|
"""Чистить все TTL-таблицы до и после каждого теста (state живёт в модуле)."""
|
||||||
weather_cache._FORECAST_CACHE.clear()
|
weather_cache._FORECAST_CACHE.clear()
|
||||||
weather_cache._CLIMATE_CACHE.clear()
|
weather_cache._CLIMATE_CACHE.clear()
|
||||||
|
weather_cache._AIR_CACHE.clear()
|
||||||
yield
|
yield
|
||||||
weather_cache._FORECAST_CACHE.clear()
|
weather_cache._FORECAST_CACHE.clear()
|
||||||
weather_cache._CLIMATE_CACHE.clear()
|
weather_cache._CLIMATE_CACHE.clear()
|
||||||
|
weather_cache._AIR_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def _make_forecast_response() -> dict[str, Any]:
|
def _make_forecast_response() -> dict[str, Any]:
|
||||||
|
|
@ -96,6 +98,18 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
|
||||||
return resp
|
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'а — снять повторные запросы).
|
# 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы).
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -338,3 +352,100 @@ class TestExpiresAfterTtl:
|
||||||
clock[0] += weather_cache._SEASONAL_TTL_S + 1
|
clock[0] += weather_cache._SEASONAL_TTL_S + 1
|
||||||
weather_cache.get_seasonal_weather_cached(56.84, 60.59)
|
weather_cache.get_seasonal_weather_cached(56.84, 60.59)
|
||||||
assert get.call_count == 2
|
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