perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B) #1195

Merged
bot-backend merged 1 commit from perf/analyze-airquality-neighbors into main 2026-06-12 17:52:22 +00:00
Collaborator

Summary

Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194 уже в проде, дал 14.88s → 4.13s p50).

После Phase A профиль показал двух следующих guzzler'ов: _fetch_air_quality_sync (0.26s в успешном hit + до 5s timeout на DNS-fail — тот же антипаттерн что был у weather) и _neighbors_summary (0.70s — два отдельных БД-запроса inline).

Что делает PR

1. air-quality TTL-кэш — третий слот в app/services/weather_cache.py рядом с forecast/climate:

  • get_air_quality_cached(lat, lon) — Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа).
  • hit-TTL 1 час (hourly bucket API — обновлять чаще forecast).
  • negative-TTL 5 минут (наследует от существующих, не дёргать сеть на DNS-fail).
  • timeout 5s → 2s.
  • single-flight под per-cache threading.Lock.
  • Реиспользует _round_key / _now / _evict_one_if_full / _NEGATIVE_TTL_S.
  • _fetch_air_quality_sync удалена из parcels.py, импорт + вызов в analyze_parcel (строка 1797) обновлён.

2. _neighbors_summary 2 SQL → 1 statement:

  • Было: SELECT neighbors LIMIT 30 + отдельный SELECT overlaps LIMIT 5 → ~2×47 ms = 94 ms сетевого RTT.
  • Стало: WITH neighbors AS (...), overlaps AS (...) SELECT COALESCE(json_agg(row_to_json(n)), '[]'::json), ... — один RTT.
  • Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors, ST_Intersects + ST_Area для overlaps, anti-fakes guards сохранены.

Формат возвращаемых dict не меняется (frontend контракт).

Ожидаемый импакт

4.13s → ~3.4-3.6s p50 cache-hit (snap 0.26s air-cache + 0.05s neighbors merge + переменная weather/seasonal cache-hit). Если этого недостаточно — следующий guzzler zone_regulation 0.89s даже на DB cache-hit (Phase C, отдельный PR).

Test plan

  • pytest tests/services/test_weather_cache.py -v16 passed (11 forecast/climate + 5 новых air-cache).
  • pytest -x tests/api/v1/195 passed, 2 skipped, 0 failed.
  • ruff check + ruff format --check → clean.
  • grep ':[a-z_]+::[a-z]' → пусто (no psycopg v3 antipattern).
  • Post-deploy: prod replay × 3 на 66:41:0304011:1124 через docker exec gendesign-backend-1 TestClient → confirm p50 ≤ 3.6s (vs Phase A baseline 4.13s).

Refs #1130

## Summary Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194 уже в проде, дал 14.88s → 4.13s p50). После Phase A профиль показал двух следующих guzzler'ов: `_fetch_air_quality_sync` (0.26s в успешном hit + до 5s timeout на DNS-fail — тот же антипаттерн что был у weather) и `_neighbors_summary` (0.70s — два отдельных БД-запроса inline). ## Что делает PR **1. air-quality TTL-кэш** — третий слот в `app/services/weather_cache.py` рядом с forecast/climate: - `get_air_quality_cached(lat, lon)` — Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа). - hit-TTL **1 час** (hourly bucket API — обновлять чаще forecast). - negative-TTL **5 минут** (наследует от существующих, не дёргать сеть на DNS-fail). - timeout **5s → 2s**. - single-flight под per-cache `threading.Lock`. - Реиспользует `_round_key` / `_now` / `_evict_one_if_full` / `_NEGATIVE_TTL_S`. - `_fetch_air_quality_sync` удалена из `parcels.py`, импорт + вызов в `analyze_parcel` (строка 1797) обновлён. **2. `_neighbors_summary` 2 SQL → 1 statement**: - Было: SELECT neighbors LIMIT 30 + отдельный SELECT overlaps LIMIT 5 → ~2×47 ms = 94 ms сетевого RTT. - Стало: `WITH neighbors AS (...), overlaps AS (...) SELECT COALESCE(json_agg(row_to_json(n)), '[]'::json), ...` — один RTT. - Семантика идентична: те же `ST_DWithin(geom, point, 100m)` для neighbors, `ST_Intersects + ST_Area` для overlaps, anti-fakes guards сохранены. Формат возвращаемых dict не меняется (frontend контракт). ## Ожидаемый импакт 4.13s → ~3.4-3.6s p50 cache-hit (snap 0.26s air-cache + 0.05s neighbors merge + переменная weather/seasonal cache-hit). Если этого недостаточно — следующий guzzler `zone_regulation` 0.89s даже на DB cache-hit (Phase C, отдельный PR). ## Test plan - [x] `pytest tests/services/test_weather_cache.py -v` → **16 passed** (11 forecast/climate + 5 новых air-cache). - [x] `pytest -x tests/api/v1/` → **195 passed, 2 skipped, 0 failed**. - [x] `ruff check` + `ruff format --check` → clean. - [x] `grep ':[a-z_]+::[a-z]'` → пусто (no psycopg v3 antipattern). - [ ] Post-deploy: prod replay × 3 на `66:41:0304011:1124` через `docker exec gendesign-backend-1 TestClient` → confirm p50 ≤ 3.6s (vs Phase A baseline 4.13s). Refs #1130
bot-backend added 1 commit 2026-06-12 17:43:21 +00:00
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
49b85ab1d6
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
bot-backend merged commit 49b85ab1d6 into main 2026-06-12 17:52:22 +00:00
bot-backend deleted branch perf/analyze-airquality-neighbors 2026-06-12 17:52:23 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1195
No description provided.