perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A) #1194

Merged
bot-backend merged 1 commit from perf/analyze-weather-ttl-cache into main 2026-06-12 16:14:10 +00:00
Collaborator

Summary

Phase A для #1130 (POST /analyze тормозит): кэш для двух Open-Meteo HTTP-вызовов в hot-path, сокращены тайм-ауты, single-flight, negative-cache на DNS-fail.

Раскопал на проде: _fetch_weather_sync (timeout=5s) + _fetch_seasonal_weather_sync (timeout=15s) сидели без кэша. В backend-контейнере прода DNS до *.open-meteo.com фейлит (private network с restricted egress), и каждый replay висел полный timeout. Replay × 3 на проде после PR #1139: p50 = 14.88 s, min = 8.02 s (DNS resolved). cProfile показал: 2.38с в httpx-стеке + 1.73с в SSL recv.

Что делает PR

Новый модуль app/services/weather_cache.py с двумя публичными функциями:

  • get_weather_cached(lat, lon) → 7-day forecast, hot-TTL 6h.
  • get_seasonal_weather_cached(lat, lon) → 30-летние climate normals, hot-TTL 7 суток (climate-data обновляется ~раз в год).
  • Negative-TTL для обоих — 5 минут (на DNS-fail не дёргаем сеть на каждый analyze).
  • Ключ: (round(lat, 2), round(lon, 2)) — ≈ 1 км, для одного cad всегда один ключ.
  • Single-flight: per-cache threading.Lock + check-then-fetch-then-store внутри lock'а. 16 параллельных потоков → ровно 1 GET (есть тест с threading.Barrier(16)).
  • time.monotonic() для expires_at (устойчиво к NTP-скачкам).
  • Soft-cap 256 записей per-cache, FIFO eviction (insertion-order dict CPython 3.7+).
  • Тайм-ауты сокращены: forecast 5s → 2s, climate 15s → 3s.
  • Любая Exceptionlogger.warning + negative-cache + None. Контракт для caller'а не изменился (None по-прежнему допустим — _build_environmental_score использует bool(weather) в env_ok).

parcels.py: удалены _fetch_weather_sync + _fetch_seasonal_weather_sync (вся логика перенесена побайтово — те же ключи в return dict), заменены два вызова в analyze_parcel на новые. -132 / +22.

7 файлов tests/api/v1/test_*.py обновили patch("...parcels._fetch_weather_sync"...)patch("...parcels.get_weather_cached"...).

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

Прод POST /analyze (cad с DNS-fail): ~14s~3-5s cold-start, далее ms на cache-hit в 6h-окне. Если останется > 3s — Phase B (per-request memo для 68 БД-запросов, ~3.2с в hot-path).

Test plan

  • pytest tests/services/test_weather_cache.py -v11/11 passed (hit, negative, изоляция forecast/climate, single-flight через Barrier(16), TTL expire через monkeypatch _now).
  • pytest -x tests/api/195 passed, 2 skipped (no regression).
  • ruff check + ruff format --check → clean.
  • code-reviewer subagent → APPROVE (no critical findings).
  • Post-deploy: smoke POST /analyze × 3 на проде через qa-tester — confirm p50 ≤ 5s (vs до 14.88s).

Refs #1130

## Summary Phase A для #1130 (`POST /analyze` тормозит): кэш для двух Open-Meteo HTTP-вызовов в hot-path, сокращены тайм-ауты, single-flight, negative-cache на DNS-fail. Раскопал на проде: `_fetch_weather_sync` (timeout=5s) + `_fetch_seasonal_weather_sync` (timeout=15s) сидели без кэша. В backend-контейнере прода DNS до `*.open-meteo.com` фейлит (private network с restricted egress), и каждый replay висел полный timeout. Replay × 3 на проде после PR #1139: `p50 = 14.88 s`, `min = 8.02 s` (DNS resolved). cProfile показал: 2.38с в httpx-стеке + 1.73с в SSL recv. ## Что делает PR Новый модуль `app/services/weather_cache.py` с двумя публичными функциями: - `get_weather_cached(lat, lon)` → 7-day forecast, hot-TTL **6h**. - `get_seasonal_weather_cached(lat, lon)` → 30-летние climate normals, hot-TTL **7 суток** (climate-data обновляется ~раз в год). - Negative-TTL для обоих — **5 минут** (на DNS-fail не дёргаем сеть на каждый analyze). - Ключ: `(round(lat, 2), round(lon, 2))` — ≈ 1 км, для одного cad всегда один ключ. - Single-flight: per-cache `threading.Lock` + check-then-fetch-then-store внутри lock'а. 16 параллельных потоков → ровно 1 GET (есть тест с `threading.Barrier(16)`). - `time.monotonic()` для expires_at (устойчиво к NTP-скачкам). - Soft-cap 256 записей per-cache, FIFO eviction (insertion-order dict CPython 3.7+). - Тайм-ауты сокращены: forecast 5s → **2s**, climate 15s → **3s**. - Любая `Exception` → `logger.warning` + negative-cache + `None`. Контракт для caller'а не изменился (None по-прежнему допустим — `_build_environmental_score` использует `bool(weather)` в `env_ok`). `parcels.py`: удалены `_fetch_weather_sync` + `_fetch_seasonal_weather_sync` (вся логика перенесена побайтово — те же ключи в return dict), заменены два вызова в `analyze_parcel` на новые. `-132 / +22`. 7 файлов `tests/api/v1/test_*.py` обновили `patch("...parcels._fetch_weather_sync"...)` → `patch("...parcels.get_weather_cached"...)`. ## Ожидаемый импакт Прод `POST /analyze` (cad с DNS-fail): `~14s` → `~3-5s` cold-start, далее **ms** на cache-hit в 6h-окне. Если останется > 3s — Phase B (per-request memo для 68 БД-запросов, ~3.2с в hot-path). ## Test plan - [x] `pytest tests/services/test_weather_cache.py -v` → **11/11 passed** (hit, negative, изоляция forecast/climate, single-flight через Barrier(16), TTL expire через monkeypatch `_now`). - [x] `pytest -x tests/api/` → **195 passed, 2 skipped** (no regression). - [x] `ruff check` + `ruff format --check` → clean. - [x] code-reviewer subagent → ✅ APPROVE (no critical findings). - [ ] Post-deploy: smoke `POST /analyze` × 3 на проде через qa-tester — confirm `p50 ≤ 5s` (vs до 14.88s). Refs #1130
bot-backend added 1 commit 2026-06-12 15:00:35 +00:00
perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A)
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
97ff0c9e81
Два внешних 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
bot-backend merged commit 97ff0c9e81 into main 2026-06-12 16:14:10 +00:00
bot-backend deleted branch perf/analyze-weather-ttl-cache 2026-06-12 16:14:10 +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#1194
No description provided.