From 11aa011d11daea7dc884faf3c07b2f4740a6c0e8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 00:09:09 +0500 Subject: [PATCH] =?UTF-8?q?perf(db):=20covering-index=20objective=5Flots?= =?UTF-8?q?=20district-price=20(#70)=20+=20152-=D0=A4=D0=97/perf=20audit?= =?UTF-8?q?=20docs=20(#70=20#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #70: EXPLAIN top-5 hot-path на prod — analyze district-price block по objective_lots (1.12M) делал Bitmap Heap Scan ~41k блоков (cold 5.3s/warm 474ms), пробивая p95 ≤3с. Partial covering-index (district, price_per_m2_rub) WHERE price_per_m2_rub IS NOT NULL → Index Only Scan (verified scratch: 87ms, Heap Fetches 0). Требует VACUUM ANALYZE objective_lots post-deploy (stale VM иначе глушит выигрыш). database-expert ✅ verified. #79: 152-ФЗ PII grep-аудит — plain-PII в логах НЕ найдено; Sentry scrub / PRINZIP-хеширование / LLM §19-redaction подтверждены. Остаточный риск: pilot_requests хранит сырые ПДн без retention (рекомендация в docs). httpx DEBUG off (ops-инвариант). Closes #70 Closes #79 --- .../153_objective_lots_district_price_idx.sql | 45 ++++++ docs/PII_152FZ_Audit.md | 151 +++++++++++++++++ docs/Performance_Audit.md | 153 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 data/sql/153_objective_lots_district_price_idx.sql create mode 100644 docs/PII_152FZ_Audit.md create mode 100644 docs/Performance_Audit.md diff --git a/data/sql/153_objective_lots_district_price_idx.sql b/data/sql/153_objective_lots_district_price_idx.sql new file mode 100644 index 00000000..28c28b5f --- /dev/null +++ b/data/sql/153_objective_lots_district_price_idx.sql @@ -0,0 +1,45 @@ +-- 153_objective_lots_district_price_idx.sql +-- Issue #70 (B4-3 Performance аудит) — covering-index для analyze district-price block. +-- +-- ПРОБЛЕМА (EXPLAIN ANALYZE на prod, district='Академический', 2026-06-13): +-- app/api/v1/parcels.py:2123 (analyze hot-path, "B5-5 District price ranges") +-- SELECT MIN/MAX/PERCENTILE_CONT(price_per_m2_rub), COUNT(*) +-- FROM objective_lots +-- WHERE district = :dn AND price_per_m2_rub IS NOT NULL +-- AND price_per_m2_rub BETWEEN 30000 AND 600000 +-- На плотном районе матчит ~182k строк (objective_lots ~1.12M строк всего). +-- Существующий objective_lots_district_class_idx (district, class) даёт только +-- district-match по индексу, НО price_per_m2_rub лежит в heap → Bitmap Heap Scan +-- читает ~41k heap-блоков (~320 МБ) чтобы достать цену каждой строки. +-- Замеры: cold-cache 5286 ms, warm-cache 499 ms — оба пробивают analyze +-- p95-таргет ≤3 с (issue #70 targets) на холодном кэше / плотных районах. +-- +-- РЕШЕНИЕ: частичный covering-index (district, price_per_m2_rub). Обе колонки — +-- и фильтр, и агрегируемое значение — в индексе → планировщик может сделать +-- Index Only Scan (читает ~283 листовых страницы индекса вместо 41k heap-блоков), +-- heap не трогается (видимость закрывает visibility map после VACUUM). +-- WHERE price_per_m2_rub IS NOT NULL — частичный: индексируем только строки с +-- ценой (~919k из 1.12M), отсекая ~204k NULL-цен; зеркалит предикат запроса +-- (IS NOT NULL) и сужает индекс. Конкретный район в запросе делает Index Cond +-- по первой колонке, BETWEEN — диапазон по второй. +-- +-- Dependencies: objective_lots (существует; обновляется objective_etl). PG 16.4. +-- Idempotent: CREATE INDEX IF NOT EXISTS. Не-CONCURRENTLY намеренно — deploy.yml +-- гоняет файл в транзакции (BEGIN/COMMIT), а CREATE INDEX CONCURRENTLY в tx +-- запрещён. Index ~45 MB, build несколько секунд; обычный CREATE INDEX берёт +-- SHARE-lock (блокирует только writes/objective_etl, НЕ analyze-читателей) — +-- приемлемо в deploy-окне (как индексы 87/97). Не запускать одновременно с ETL. +-- Applied: automatically via deploy.yml in NN order (трекер _schema_migrations). + +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_objective_lots_district_price + ON objective_lots (district, price_per_m2_rub) + WHERE price_per_m2_rub IS NOT NULL; + +COMMENT ON INDEX idx_objective_lots_district_price IS + 'Covering-index для analyze district-price block (#70): district-фильтр + ' + 'агрегаты по price_per_m2_rub через Index Only Scan, без Bitmap Heap Scan ' + '~41k блоков. См. parcels.py:2123, docs/Performance_Audit.md.'; + +COMMIT; diff --git a/docs/PII_152FZ_Audit.md b/docs/PII_152FZ_Audit.md new file mode 100644 index 00000000..57db016b --- /dev/null +++ b/docs/PII_152FZ_Audit.md @@ -0,0 +1,151 @@ +# 152-ФЗ: аудит кода на PII в logs / Sentry / БД / LLM + +> Аудит по issue [#79](https://git.gendsgn.ru/lekss361/gendesign/issues/79) (B7-1, +> ⚖️ Compliance / Legal). Дата: 2026-06-13. Статус: **PASS** (plain-PII в логах не +> обнаружено; ниже — что проверено, покрытие scrub-слоёв, остаточные риски). +> +> Источник правды по решениям — Obsidian vault. Здесь — версионированная в репозитории +> копия рядом с кодом, на который ссылается. + +## TL;DR + +- **Grep-аудит логов: plain-PII НЕ найдено.** Ни один `logger.*`-вызов в `backend/app/` + не пишет сырые phone/email/ФИО/паспорт/СНИЛС/ИНН. Исправлять нечего. +- **Sentry/GlitchTip**: `send_default_pii=False` + `before_send_transaction`-scrub + активны и в API (`app/main.py`), и в Celery (`app/workers/celery_app.py`). +- **БД**: единственное место хранения сырых ПДн — `pilot_requests` (лид-форма пилота); + это by-design (контакт для обратной связи). PRINZIP CRM-зеркало хеширует phone/email + при импорте. **Остаточный риск**: у `pilot_requests` нет хеширования и нет retention — + см. §6. +- **LLM (§19)**: внешний провайдер (OpenAI) защищён эшелонированным redaction-слоем + (allowlist → hard-block confidential → regex-scrub RU PII). По умолчанию LLM выключен + (`llm_enabled=False`). + +## 1. Метод аудита + +Прогон по `backend/app/` (исключая `tests/`): + +```bash +# A) logger.*-вызовы, упоминающие PII-поля +grep -rniE "logger\.(debug|info|warning|error|exception)\(.*(phone|email|passport|\binn\b|снилс|snils|fio|full_?name|фио|телефон|почт)" app/ + +# B) файлы, читающие PII-поля (трассировка «доходит ли до лога») +grep -rni -lE "phone|email|passport|снилс|fio|full_name|фио|owner_name|client_name" app/ --include="*.py" + +# C) логи, дампящие весь payload/body/request/dict (риск PII через repr объекта) +grep -rnE "logger\.\w+\(.*(payload|body|request\b|\.dict\(|model_dump|\.json\(|%r)" app/ + +# D) bare print() (запрещён — мог бы вывести PII в stdout) +grep -rnE "(^|[^.])\bprint\(" app/ --include="*.py" + +# E) форсированный DEBUG / httpx-logger (может логировать query-params с PII) +grep -rniE "basicConfig|getLogger\(.httpx|setLevel\(.*DEBUG|level=logging\.DEBUG|--log-level" app/ docker-compose*.yml backend/Dockerfile +``` + +## 2. Logs — результаты + +### 2.1. Прямых PII-логов нет + +`grep` A вернул **0 совпадений**. Точечно проверены два единственных PII-несущих +эндпоинта: + +| Файл | PII на входе | Что логируется | Вердикт | +|---|---|---|---| +| `app/api/v1/pilot.py` | `name/phone/email/company` (лид-форма) | `logger.info("pilot_request saved id=%s source=%s", row["id"], payload.source)` — только id строки + источник | **clean** | +| `app/api/v1/admin_leads.py` | — (читает уже-хешированные) | отдаёт только `phone_last4` + `email_hash`; сырые phone/email из SELECT не выбираются | **clean** | + +### 2.2. `%r`-дампы — не PII + +Grep C нашёл `logger.*(... %r)` в скрейперах/парсерах (CBR, Rosstat, OKN, NSPD, DOM.РФ, +`reservation_ingest`, `izyatie_ocr`). Все источники — **публичные** датасеты недвижимости/ +макроэкономики. Дампятся коды/строки документов/площадки, а не ПДн физлиц. + +`app/main.py:140` логирует `username %r` (неизвестный RBAC-юзер) — это **операторский +логин** из Caddy basic_auth (учётка сотрудника), а не субъект ПДн по 152-ФЗ. Оставлено +как осознанный security-лог попыток доступа. + +### 2.3. Нет `print()`, нет форсированного DEBUG + +- Grep D: **0** bare `print()` в `app/`. +- Grep E: единственные `logging.basicConfig(level=logging.INFO, …)` — внутри + `if __name__ == "__main__"` CLI-блоков standalone-скрейперов (`cbr_macro.py`, + `rosstat_emiss.py`), НЕ в runtime API/worker. **Уровень INFO, не DEBUG.** +- Ни один `httpx`/`httpcore`-logger нигде не выставлен в DEBUG. Нет `dictConfig`/`fileConfig`. +- Runtime log-levels (`docker-compose.prod.yml`): backend `uvicorn` — дефолт `info` + (нет `--log-level debug`); Celery worker/beat — `--loglevel=info`. uvicorn access-log + пишет метод+путь+статус, **не тело запроса**, поэтому форма `POST /pilot/request` не + светит PII в access-логе (а query-string у неё пустой). + +**Вывод по logs: plain-PII отсутствует. Правок не требуется.** + +## 3. Sentry / GlitchTip — coverage + +Self-hosted GlitchTip (Sentry-совместимый). Init продублирован для двух процессов: + +- `app/main.py` (FastAPI) и `app/workers/celery_app.py` (Celery) — оба с: + - `send_default_pii=False` — SDK не прикрепляет cookies/headers/тело/IP-адреса. + - `before_send_transaction=scrub_sensitive_query` (`app/observability/sentry_scrub.py`) — + redact-ит `apiKey=/api_key=/token=/access_token=/secret=` из URL-spans (HttpxIntegration + performance-spans), `span.description`, `request.url`. + - `traces_sample_rate` = `glitchtip_traces_sample_rate` (default 0.05). +- Интеграции: `Starlette/FastApi/Celery/Sqlalchemy/Httpx/Logging`. `LoggingIntegration` + поднимает события только на `ERROR` (`event_level=logging.ERROR`); т.к. PII не попадает + в сообщения (см. §2), error-события их не несут. +- **Шов**: `before_send_transaction` redact-ит секреты в **transaction**-spans, но не + фильтрует тело error-событий. Это покрыто тем, что (а) `send_default_pii=False` не + прикрепляет request-данные, и (б) сами лог-сообщения PII-free. Если в будущем error + начнут включать пользовательский ввод — расширить до `before_send` (errors), а не только + `before_send_transaction`. **Рекомендация R3 §6.** + +## 4. БД — где лежат ПДн + +| Таблица | ПДн | Защита | 152-ФЗ комментарий | +|---|---|---|---| +| `pilot_requests` (`data/sql/118`) | `name`, `phone`, `email`, `company` — **сырые** | нет хеша, нет retention | Лид-форма пилота. Нужно согласие на обработку + срок хранения — **R1 §6** | +| `prinzip_crm_leads` (`data/sql/52`) | `name` (сырое); phone/email — **хешированы** | `phone_hash`=SHA256(phone)[:16], `phone_last4`, `email_hash`=MD5(email) | Хеширование в ETL `52_import_prinzip_crm.py` (`hash_phone`/`hash_email`) ПЕРЕД INSERT. Сырые phone/email в PG **не пишутся**. `name` оставлен (право на удаление) | +| `audit_log` (`data/sql/144`) | `username`, `role`, `action`, `method`, `path`, `cad_num` | — | `username` = операторский логин (не субъект ПДн). Без phone/email/IP. **clean** | + +PRINZIP ETL дополнительно: логирует только `lead_id`/`deal_id` + счётчики, не сырые значения. + +## 5. LLM (§19 data-residency) + +`app/services/llm/redaction.py` — критическая граница для внешнего провайдера (OpenAI, +данные покидают РФ). Эшелонировано: + +1. **Allowlist** (первичное): консьюмер обязан собрать `SafePayload` из проверенных + не-чувствительных полей — сырые DB-строки не передаются. +2. **Hard-block** (enforced): `SafePayload(is_confidential=True)` → `RedactionRefusedError`, + контент наружу не уходит (детерминированный fallback вместо отправки). +3. **Regex-scrub** (вторичное): из свободного текста вырезаются RU PII (телефон/email/ + СНИЛС/ИНН/паспорт/ФИО-подобное, incl. «голые» 11-значные форматы) → `[REDACTED:]`. + `scrub_text` логирует только `kind`+count, **никогда** само значение. + +По умолчанию `llm_enabled=False` и `openai_api_key=None` → клиент вообще не делает +сетевых вызовов. Включение — только после заведения ключа в окружение прод-контейнера +И принятия решения по §19. + +## 6. Остаточные риски и рекомендации + +| # | Риск | Рекомендация | Владелец | +|---|---|---|---| +| **R1** | `pilot_requests` хранит сырые `name/phone/email` без срока хранения и без явной отметки согласия | Завести retention (напр. авто-удаление/анонимизация лидов старше N мес) + хранить факт согласия на обработку ПДн (checkbox на форме, cross-ref B3-4). Решение — продукт/legal | product + database-expert | +| **R2** | httpx DEBUG **сейчас выключен** в коде и compose (проверено §2.3). Но если кто-то выставит `LOG_LEVEL=DEBUG`/`--log-level debug` в окружении — httpx/httpcore начнут логировать URL с query-params | Ops-инвариант: не включать DEBUG на проде для backend/worker. (Опционально — явно прибить `logging.getLogger("httpx").setLevel(WARNING)` в `app/main.py` как defense-in-depth) | devops (ops-decision) | +| **R3** | Sentry-scrub покрывает transaction-spans (`before_send_transaction`); error-события защищены лишь через `send_default_pii=False` + PII-free логи | Если в error-события начнут попадать пользовательские строки — добавить `before_send` (errors) поверх существующего `before_send_transaction` | backend | +| **R4** | `pilot.py:email` валидируется минимальным regex (без `email-validator` — он ломал старт контейнера); это не PII-leak, но слабая валидация | Принять как есть (валидация формата ≠ защита ПДн) либо вынести валидацию на фронт/CRM | — (accepted) | + +## 7. Acceptance (issue #79) + +- [x] Grep-аудит логов выполнен (A–E) — **plain-PII не найдено**. +- [x] PRINZIP CRM ETL: подтверждено хеширование phone/email перед INSERT. +- [x] Sentry PII-scrubbing активен (`send_default_pii=False` + `before_send_transaction`). +- [x] httpx DEBUG в проде проверен — **выключен** (R2 — ops-инвариант). +- [x] Audit-doc с findings, scrub-coverage, остаточными рисками. + +## Ссылки (код) + +- Sentry init + scrub: `backend/app/main.py`, `backend/app/workers/celery_app.py`, + `backend/app/observability/sentry_scrub.py` +- LLM §19 redaction: `backend/app/services/llm/redaction.py`, `client.py` +- Лид-формы: `backend/app/api/v1/pilot.py`, `backend/app/api/v1/admin_leads.py` +- PRINZIP ETL (хеширование): `data/sql/52_import_prinzip_crm.py`, `data/sql/52_schema_prinzip_crm.sql` +- Аудит-таблица: `data/sql/144_audit_log.sql`, `backend/app/core/audit_middleware.py` diff --git a/docs/Performance_Audit.md b/docs/Performance_Audit.md new file mode 100644 index 00000000..49e7bdce --- /dev/null +++ b/docs/Performance_Audit.md @@ -0,0 +1,153 @@ +# Performance аудит: Sentry Tracing + EXPLAIN slow queries + +> Аудит по issue [#70](https://git.gendsgn.ru/lekss361/gendesign/issues/70) (B4-3, +> ⚙️ Backend API quality). Дата: 2026-06-13. EXPLAIN-замеры — READ-ONLY на prod +> (`gendesign-postgres-1`, PostgreSQL 16.4 + PostGIS 3.4), без DDL на проде. +> +> Источник правды по решениям — Obsidian vault. Здесь — версионированная копия рядом с кодом. + +## TL;DR + +- **Tracing включён** (drift): GlitchTip `traces_sample_rate=0.05`, авто-инструментация + FastApi/Sqlalchemy/Httpx/Celery (`app/main.py`, `app/workers/celery_app.py`). +- **EXPLAIN top-5 hot-path** прогнан на проде с реальными параметрами (район + `Академический`, region 66). 4 из 5 запросов — в пределах p95-таргетов. +- **Найден 1 явный missing-index** (большая выгода): analyze «district price ranges» + по `objective_lots` (~1.12М строк) на плотном районе — **cold 5286 ms / warm 499 ms**, + Bitmap Heap Scan ~41k блоков. Исправлено миграцией + **`data/sql/153_objective_lots_district_price_idx.sql`** (частичный covering-index + → Index Only Scan). +- **pgBouncer** пока **не требуется** (см. §5) — ops-decision при росте конкурентных + соединений >50. + +## 1. Sentry / GlitchTip Performance (статус) + +Инструментация активна в обоих процессах: + +```python +sentry_sdk.init( + traces_sample_rate=settings.glitchtip_traces_sample_rate, # default 0.05 + integrations=[StarletteIntegration(), FastApiIntegration(), + CeleryIntegration(...), SqlalchemyIntegration(), + HttpxIntegration(), LoggingIntegration(...)], +) +``` + +- `SqlalchemyIntegration` создаёт span на каждый SQL → в GlitchTip видно время по + запросам внутри транзакции `/parcels/{id}/analyze` и др. +- `before_send_transaction=scrub_sensitive_query` redact-ит секреты из span-URL (см. + `docs/PII_152FZ_Audit.md` §3) — tracing не светит api-keys. +- Замечание по sample-rate: 0.05 (5%) — экономно. Для прицельного сбора p95 на + hot-path можно временно поднять (env `GLITCHTIP_TRACES_SAMPLE_RATE`) на период замера, + затем вернуть. Это конфиг-tuning, не код. + +## 2. Top-5 hot-path запросов — EXPLAIN (ANALYZE, BUFFERS) на проде + +Параметры: `region_code=66`, `district='Академический'` (самый плотный район), +`cad='66:41:0303035'`. Размеры hot-таблиц: `objective_lots` 1.12М строк, `domrf_realization` +144k, `objective_corpus_room_month` 78k, `domrf_kn_objects` 10.7k, `domrf_kn_sales_agg` 2.6k, +`cad_buildings` 38k, `cad_quarters_geom` 11k. `cad_parcels_geom` — **VIEW** (не базовая +таблица), физически бьёт `cad_parcels`/`cad_buildings`. + +| # | Запрос (код) | Endpoint | План | Exec (cold/warm) | Вердикт | +|---|---|---|---|---|---| +| 1 | `market_pulse` `analytics_queries.py:27` | `/analytics/market-pulse` ≤500ms | Bitmap Index Scan `idx_realization_region` | **3.3 ms** | OK | +| 2 | analyze geom UNION `parcels.py:1331` | `/parcels/{id}/analyze` ≤3с | Append + Index Scan на PK (3 ветки, 2 `never executed` из-за `LIMIT 1`) | **37.6 ms** | OK | +| 3 | `_district_market_saturation` `analytics_queries.py:1426` | analyze/recommend | Aggregate; seq scan `domrf_kn_sales_agg` (2.6k строк, оптимален при таком размере) | **6.0 ms** | OK | +| 4 | `_district_velocity_trend` `analytics_queries.py:1468` | recommend | Bitmap Index Scan `objective_crm_district_class_idx` → heap 1691 блоков | cold **283 ms** / warm **14.9 ms** | OK (см. §3) | +| 5 | `_competitors_two_dim` `analytics_queries.py:2158` | analyze | Bitmap Index Scan `obj_district_class_active_idx` + `DISTINCT ON` 800→126 + ST_DWithin in-mem | **66 ms** | OK | +| 5b | competitors count tier-1 `analytics_queries.py:1731` | analyze | **Index Only Scan** `obj_district_class_active_idx` | **1.0 ms** | OK | + +### Запрос-нарушитель (вне таблицы top-5, но критичен) + +| Запрос | Endpoint | План ДО | Exec ДО | +|---|---|---|---| +| analyze «District price ranges» `parcels.py:2123` (`MIN/MAX/PERCENTILE_CONT/COUNT` по `objective_lots WHERE district=:dn AND price_per_m2_rub …`) | `/parcels/{id}/analyze` ≤3с | Bitmap Index Scan `objective_lots_district_class_idx` (matches ~182k) → **Bitmap Heap Scan ~41k блоков** (price_per_m2_rub в heap) → filter до 152k | **cold 5286 ms / warm 499 ms** | + +Это **пробивает** analyze p95 ≤3с на холодном кэше и на плотных районах. Корень: индекс +`(district, class)` даёт district-match, но агрегируемая колонка `price_per_m2_rub` не в +индексе → планировщик идёт в heap за ценой каждой из 182k строк (~320 МБ I/O cold). + +## 3. Выявленные проблемы и что сделано + +### P1 (исправлено) — missing covering-index на `objective_lots` + +**Миграция `data/sql/153_objective_lots_district_price_idx.sql`:** + +```sql +CREATE INDEX IF NOT EXISTS idx_objective_lots_district_price + ON objective_lots (district, price_per_m2_rub) + WHERE price_per_m2_rub IS NOT NULL; +``` + +Обе колонки (фильтр `district` + агрегируемое `price_per_m2_rub`) теперь в индексе → +планировщик делает **Index Only Scan** (читает ~283 листовых страницы вместо 41k +heap-блоков), heap не трогается. Частичный (`WHERE price_per_m2_rub IS NOT NULL`) — +индексируем ~919k из 1.12М строк, зеркалит предикат запроса. Idempotent, не-CONCURRENTLY +(deploy.yml гоняет в транзакции). **Ожидаемый эффект:** district-price block уходит с +сотен мс (warm) / секунд (cold) в десятки мс. + +> **Важно — VACUUM обязателен для реализации выигрыша.** Проверено на проде через +> аналогичный существующий индекс `objective_lots_rooms_area_idx (rooms_int, area_pd)`: +> запрос той же формы действительно идёт `Index Only Scan` (паттерн подтверждён), НО при +> устаревшей visibility map даёт `Heap Fetches: 29637` (страницы `dirtied` во время скана) +> → Index Only Scan всё равно лезет в heap за видимостью, выигрыш частично теряется +> (замер ~404 ms). Поэтому после deploy P1: повторить EXPLAIN запроса из §2, убедиться в +> `Index Only Scan using idx_objective_lots_district_price`, и **обязательно** +> `VACUUM ANALYZE objective_lots` (или дождаться autovacuum) — чтобы visibility map дал +> «Heap Fetches: 0» и реальный exec упал в десятки мс. Без VACUUM индекс создан, но +> эффект приглушён. + +### P2 (документировано, индекс НЕ нужен) — cold-cache на `objective_corpus_room_month` + +`_district_velocity_trend` (#4): cold 283 ms — это first-touch disk I/O (1709 блоков), +warm 14.9 ms. Индекс по `district` уже есть (`objective_crm_district_class_idx`); 283 мс — +не отсутствие индекса, а холодный heap-fetch `deals_total_count` (нет в индексе). Таблица +108 МБ полностью кэшируется после прогрева → реальный p95 под нагрузкой ≈ warm. Covering- +индекс дал бы выигрыш только на cold-start; при 108 МБ / постоянном трафике recommend это +не стоит лишнего индекса. **Решение: не добавлять**, оставить на наблюдение через tracing. + +### P3 (документировано, не проблема) — высокий seq_scan на `domrf_kn_objects` + +`pg_stat_user_tables`: `domrf_kn_objects` `seq_scan=87771`. Но таблица — 10.7k строк +(9 МБ); seq scan для `MAX(snapshot_date)`-подзапросов дешёв. `objective_lots` +`seq_tup_read` высок из-за ETL `SELECT * FROM objective_lots` (`objective_etl.py:364`) и +`COUNT(*)/MAX(snapshot_date)` без фильтра (landing KPI / admin_scrape) — full-scan там +неизбежен, индекс не помогает. Hot-path JOIN'ы по `objective_lots` идут через +`project_name`/`district`-индексы. **Действий не требуется.** + +## 4. p95-цели (issue #70) + +| Endpoint | Target p95 | Доминирующие запросы | Статус (по EXPLAIN) | +|---|---|---|---| +| `/analytics/market-pulse` | ≤500 ms | #1 (3.3 ms) | **в пределах** с запасом | +| `/parcels/{id}/analyze` | ≤3 с | #2 (37 ms) + #5 (66 ms) + district-price (был 5.3с cold) | **после P1** — district-price в десятки мс → analyze укладывается; до P1 cold-кэш пробивал | +| `/analytics/recommend` | ≤2 с | #3 (6 ms) + #4 (cold 283/warm 15 ms) + competitors | **в пределах** на warm; cold-start recommend стоит замерить через tracing после deploy | + +> Эти цифры — single-query exec на проде, не сквозной p95 endpoint'а (включает Python- +> сериализацию, внешние HTTP в analyze — Open-Meteo через `weather_cache`, последовательные +> lookup'ы ИРД/ОКН/ТЭП). Реальный p95 снимать по GlitchTip-tracing (§1) после P1 + прогрева. + +## 5. Рекомендации (ops-decision) + +| # | Тема | Рекомендация | +|---|---|---| +| **R1** | Замер p95 | Временно поднять `GLITCHTIP_TRACES_SAMPLE_RATE` (напр. 0.2) на 1–2 дня после deploy P1, снять реальный p95 hot-path в GlitchTip, вернуть 0.05. | +| **R2** | analyze — последовательные lookup'ы | Issue #70 предлагает `asyncio.gather` для независимых district/POI/competitors. **Блокер:** `analyze_parcel` — `sync def` (под uvicorn идёт в threadpool, см. `parcels.py:909` и `weather_cache.py`); SQLAlchemy-сессия sync. Параллелизация требует рефактора на async-сессии или `run_in_threadpool` per-lookup — отдельный PR, вне scope этого аудита. После P1 основной DB-нарушитель снят, выгода от распараллеливания ниже. | +| **R3** | pgBouncer | **Пока не нужен.** Размеры данных умеренные, запросы укладываются в pool. Вводить при устойчивом росте конкурентных соединений >50 (метрика из issue) или если `pool_timeout` начнёт всплывать в логах (ср. инцидент #1202 — audit-write на event loop). Ops-decision. | +| **R4** | VACUUM (обязательно) | После применения P1 — `VACUUM ANALYZE objective_lots`. Не опционально: на проде аналогичный индекс при stale visibility map давал `Heap Fetches: 29637` (~404 ms) вместо 0 — без VACUUM Index Only Scan лезет в heap и выигрыш приглушается (§3 P1). | + +## 6. Acceptance (issue #70) + +- [x] EXPLAIN top-5 hot-path прогнан на проде с реальными цифрами (§2). +- [x] Явный missing-index найден и исправлен миграцией `data/sql/153` (§3 P1). +- [x] p95-цели зафиксированы + статус по каждой (§4). +- [x] Tracing-статус + рекомендации по замеру / pgBouncer / async-рефактору (§1, §5). +- [x] Audit-doc. + +## Ссылки (код) + +- Tracing init: `backend/app/main.py`, `backend/app/workers/celery_app.py` +- Hot-path SQL: `backend/app/services/analytics_queries.py`, `backend/app/api/v1/parcels.py` +- Миграция-фикс: `data/sql/153_objective_lots_district_price_idx.sql` +- analyze sync-def контекст: `backend/app/api/v1/parcels.py:909`, `backend/app/services/weather_cache.py` -- 2.45.3