From 73feb757675b87b57ec7457a386731613f8be84c Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 16:46:41 +0700 Subject: [PATCH] perf: add reproducible query plan gate --- README.md | 2 + deploy/README.md | 8 ++- deploy/query-plan-gate.sql | 117 +++++++++++++++++++++++++++++++++++++ deploy/test-query-plans.sh | 40 +++++++++++++ docs/ROADMAP.md | 2 +- docs/query-performance.md | 16 ++++- 6 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 deploy/query-plan-gate.sql create mode 100755 deploy/test-query-plans.sh diff --git a/README.md b/README.md index bca6556..f4f802b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. +До сервера запросы проверяются командой `./deploy/test-query-plans.sh`: session-local TEMP-fixture на 100 000 уловов не меняет рабочую БД и требует индексные планы для activity, records и spot detail, а также выполнение пяти публичных планов быстрее 250 мс. Методика и последний локальный результат находятся в [docs/query-performance.md](docs/query-performance.md); новые индексы по текущему измерению не требуются. + В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые. Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`. diff --git a/deploy/README.md b/deploy/README.md index e70aedc..3261718 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -69,6 +69,12 @@ curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json ./deploy/test-release-upgrade.sh ``` +До полевого замера query plans можно проверить на session-local наборе из 100 000 уловов. Команда не изменяет рабочие таблицы и удаляет TEMP-fixture вместе с psql-сессией: + +```bash +./deploy/test-query-plans.sh +``` + По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года. Одноразовый `migrate` применяет Alembic до rollout API; при ошибке новый runtime не запускается. API при старте выполняет только идемпотентный seed: в production он добавляет минимальные справочники и точки, а демонстрационные уловы жёстко отключены `SEED_DEMO_DATA=false`. @@ -90,7 +96,7 @@ MinIO health URL допустим для диагностики, но Console н docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records ``` -Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging. +Community scheduler входит в production-контур отдельным процессом и публикует только полные записи с ранее подтверждёнными алиасами; остальные данные остаются в staging. Все endpoint одной площадки разделяют PostgreSQL-cooldown не менее 30 минут, включая ошибки. Ручной запуск `python -m app.cli fetch-community SOURCE` использует тот же журнал и не обходит ограничение. Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику. diff --git a/deploy/query-plan-gate.sql b/deploy/query-plan-gate.sql new file mode 100644 index 0000000..f039110 --- /dev/null +++ b/deploy/query-plan-gate.sql @@ -0,0 +1,117 @@ +\set ON_ERROR_STOP on +\timing off + +-- Session-local alpha-sized fixture. No production row is inserted or changed. +SET search_path = pg_temp, public; +SET jit = off; +SET work_mem = '16MB'; + +CREATE TEMP TABLE fish (LIKE public.fish INCLUDING ALL); +CREATE TEMP TABLE waterbody (LIKE public.waterbody INCLUDING ALL); +CREATE TEMP TABLE bait (LIKE public.bait INCLUDING ALL); +CREATE TEMP TABLE spot (LIKE public.spot INCLUDING ALL); +CREATE TEMP TABLE catch_report (LIKE public.catch_report INCLUDING ALL); + +INSERT INTO fish (id, slug, name_ru, trophy_weight_g) +SELECT md5('fish-' || g)::uuid, 'fish-' || g, 'Рыба ' || g, 5000 + g * 20 +FROM generate_series(1, 252) AS g; + +INSERT INTO waterbody (id, slug, name_ru, unlock_level) +SELECT md5('water-' || g)::uuid, 'water-' || g, 'Водоём ' || g, g +FROM generate_series(1, 19) AS g; + +INSERT INTO bait (id, name, normalized_name, kind) +SELECT md5('bait-' || g)::uuid, 'Приманка ' || g, 'приманка-' || g, + CASE WHEN g % 3 = 0 THEN 'bait'::baitkind ELSE 'lure'::baitkind END +FROM generate_series(1, 500) AS g; + +INSERT INTO spot (id, waterbody_id, x, y, description) +SELECT md5('spot-' || g)::uuid, + md5('water-' || ((g - 1) % 19 + 1))::uuid, + (g * 37) % 1000, (g * 61) % 1000, NULL +FROM generate_series(1, 5000) AS g; + +INSERT INTO catch_report ( + id, fish_id, spot_id, waterbody_id, bait_id, weight_g, fishing_method, + caught_at, reported_at, player_name, source_type, source_url, + source_external_id, source_confidence, moderation_status, deleted_at, raw_payload +) +SELECT md5('report-' || g)::uuid, + md5('fish-' || ((g - 1) % 252 + 1))::uuid, + md5('spot-' || ((g - 1) % 5000 + 1))::uuid, + md5('water-' || (((g - 1) % 5000) % 19 + 1))::uuid, + md5('bait-' || ((g - 1) % 500 + 1))::uuid, + 100 + (g * 97) % 50000, + (ARRAY['float', 'bottom', 'spinning'])[(g - 1) % 3 + 1], + clock_timestamp() - (((g * 97) % 7776000) * interval '1 second'), + clock_timestamp() - (((g * 97) % 7776000) * interval '1 second'), + 'player-' || (g % 2000), + CASE WHEN g % 5 = 0 THEN 'official_record'::sourcetype ELSE 'user'::sourcetype END, + 'https://example.invalid/catches/' || g, + 'query-plan-' || g, + 50 + g % 51, + CASE WHEN g % 10 = 1 THEN 'pending'::moderationstatus ELSE 'approved'::moderationstatus END, + CASE WHEN g % 50 = 0 THEN clock_timestamp() ELSE NULL END, + json_build_object('category', CASE WHEN g % 2 = 0 THEN 'weekly' ELSE 'absolute' END, 'region', 'RU') +FROM generate_series(1, 100000) AS g; + +ANALYZE fish; +ANALYZE waterbody; +ANALYZE bait; +ANALYZE spot; +ANALYZE catch_report; + +\echo 'DATASET' +SELECT count(*) AS reports, + count(*) FILTER (WHERE moderation_status = 'approved') AS approved, + count(*) FILTER (WHERE source_type = 'official_record') AS official, + count(DISTINCT spot_id) AS spots +FROM catch_report; + +\echo 'PLAN activity_72h' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT cr.* +FROM catch_report AS cr +LEFT JOIN fish AS f ON f.id = cr.fish_id +LEFT JOIN waterbody AS w ON w.id = cr.waterbody_id +LEFT JOIN spot AS s ON s.id = cr.spot_id +LEFT JOIN bait AS b ON b.id = cr.bait_id +WHERE cr.moderation_status = 'approved' + AND cr.deleted_at IS NULL + AND cr.spot_id IS NOT NULL + AND cr.reported_at >= now() - interval '72 hours'; + +\echo 'PLAN records_count' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT count(cr.id) +FROM catch_report AS cr +WHERE cr.source_type = 'official_record'; + +\echo 'PLAN records_page' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT cr.id, cr.fish_id, cr.waterbody_id, cr.bait_id, cr.weight_g, cr.caught_at +FROM catch_report AS cr +WHERE cr.source_type = 'official_record' +ORDER BY cr.caught_at DESC, cr.weight_g DESC, cr.id DESC +LIMIT 50; + +\echo 'PLAN spot_detail' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT cr.* +FROM catch_report AS cr +LEFT JOIN bait AS b ON b.id = cr.bait_id +WHERE cr.spot_id = md5('spot-2499')::uuid + AND cr.moderation_status = 'approved' + AND cr.deleted_at IS NULL; + +\echo 'PLAN public_spot_pages' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT DISTINCT w.slug, s.x, s.y, f.slug +FROM catch_report AS cr +JOIN spot AS s ON cr.spot_id = s.id +JOIN waterbody AS w ON s.waterbody_id = w.id +JOIN fish AS f ON cr.fish_id = f.id +WHERE cr.moderation_status = 'approved' + AND cr.deleted_at IS NULL +ORDER BY w.slug, s.x, s.y, f.slug +LIMIT 500; diff --git a/deploy/test-query-plans.sh b/deploy/test-query-plans.sh new file mode 100755 index 0000000..3d23d33 --- /dev/null +++ b/deploy/test-query-plans.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output=$(mktemp) +cleanup() { rm -f "$output"; } +trap cleanup EXIT INT TERM + +docker compose exec -T db psql -X -U rf4 -d rf4_spotter \ + < "$repo/deploy/query-plan-gate.sql" > "$output" + +grep -Eq '100000[[:space:]]*\|[[:space:]]*90000[[:space:]]*\|[[:space:]]*20000[[:space:]]*\|[[:space:]]*5000' "$output" + +for plan in activity_72h records_count records_page spot_detail public_spot_pages; do + grep -F "PLAN $plan" "$output" >/dev/null +done + +# Public query budget from docs/query-performance.md. Five EXPLAIN statements +# must finish below it on the local alpha-sized fixture. +awk ' + /Execution Time:/ { count += 1; if (($3 + 0) > 250) { print "Query plan exceeded 250 ms: " $0 > "/dev/stderr"; failed = 1 } } + END { if (count != 5 || failed) exit 1 } +' "$output" + +# Selective paths must use an index. public_spot_pages may legitimately scan +# many approved rows while producing the distinct sitemap set. +awk ' + /PLAN activity_72h/ { section = "activity"; next } + /PLAN records_count/ { section = "records_count"; next } + /PLAN records_page/ { section = "records_page"; next } + /PLAN spot_detail/ { section = "spot_detail"; next } + /PLAN public_spot_pages/ { section = "public_spot_pages"; next } + section == "activity" && /Index Scan/ { activity = 1 } + section == "records_page" && /Index Scan/ { records = 1 } + section == "spot_detail" && /Index Scan/ { spot = 1 } + END { if (!activity || !records || !spot) exit 1 } +' "$output" + +grep 'Execution Time:' "$output" +echo "Query-plan gate passed: 100000 temporary reports, indexed selective paths, all plans under 250 ms" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3962a5e..f85b826 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -64,7 +64,7 @@ Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog. - [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены. -- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95. +- [x] **Q12 · Query-plan gate.** Воспроизводимый TEMP-only fixture создаёт 100 000 уловов, 5 000 точек, 252 рыбы и 19 водоёмов без изменения рабочей БД; gate снимает `EXPLAIN (ANALYZE, BUFFERS)` для activity, records count/page, spot detail и public spot pages, требует index scan у селективных путей и бюджет 250 мс. Повторный прогон: 1,58 / 13,66 / 0,55 / 0,14 / 29,61 мс. Планы подтвердили существующие индексы; новый `fish_id`-индекс, SQL-агрегация и materialized view не добавлялись без оснований. Production p95 остаётся задачей после сервера. - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [x] **Q14 · Полная CSP.** Каждый Astro SSR-ответ получает отдельный nonce для динамического JSON-LD и собственный строгий CSP; `FILES_DOMAIN` валидируется как hostname. Production запрещает inline handlers, `unsafe-inline`, eval, wildcard и HTTP; page scripts/styles остаются same-origin `_astro`-ассетами, style/script attributes запрещены. Caddy сохраняет upstream policy и использует строгий fallback для API. Bootstrap проверяет совпадение nonce на главной, report и admin и доступность OG; реальный signed screenshot проверяется после наполнения production MinIO. - [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись. diff --git a/docs/query-performance.md b/docs/query-performance.md index 56385e8..c82418d 100644 --- a/docs/query-performance.md +++ b/docs/query-performance.md @@ -1,6 +1,6 @@ # Бюджет запросов для альфа-пилота -Дата фиксации: 7 сентября 2026 года. +Дата фиксации: 13 сентября 2026 года. ## Контракт списочных API @@ -31,8 +31,20 @@ Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц. +## Воспроизводимый локальный gate + +`deploy/test-query-plans.sh` создаёт только session-local TEMP-копии production-таблиц и их индексов, загружает 100 000 уловов, 5 000 точек, 252 рыбы, 19 водоёмов и 500 приманок, выполняет `ANALYZE` и пять реальных форм запросов. После закрытия psql-сессии fixture исчезает; рабочая схема и данные не меняются. + +```bash +./deploy/test-query-plans.sh +``` + +Контрольный повторный прогон 13 сентября 2026 года на локальном PostgreSQL 17: activity 72h — 1,58 мс и bitmap index scan по статусу/времени; records count — 13,66 мс; records page — 0,55 мс и backward index scan; spot detail — 0,14 мс и bitmap index scan; public spot pages — 29,61 мс с индексными join по водоёму, точке и рыбе. Все пять планов уложились в 250 мс. Это сравнительный локальный gate, а не production p95. + +Измерение не подтвердило необходимость нового индекса с `fish_id`, SQL-агрегации или materialized view. Activity выбирает существующий временной индекс, точка — `ix_catch_report_spot_feed`, records — `ix_catch_report_official_records`; дублировать их нельзя. Gate проверяет бюджет и наличие index scan у селективных activity/records/spot путей, не фиксируя нестабильную полную строку плана. + ## Проверка после загрузки пилотных данных После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. -Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL. +Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).