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

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:
Light1YT 2026-06-12 22:42:32 +05:00
parent 97ff0c9e81
commit 49b85ab1d6
10 changed files with 253 additions and 102 deletions

View file

@ -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).

View file

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

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

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