perf: add reproducible query plan gate

This commit is contained in:
ik
2026-09-13 16:46:41 +07:00
parent abe51b38d0
commit 73feb75767
6 changed files with 181 additions and 4 deletions
+2
View File
@@ -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. Для измерений на собственном сервере подготовлен 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 MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`. Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
+7 -1
View File
@@ -69,6 +69,12 @@ curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json
./deploy/test-release-upgrade.sh ./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 года. По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
Одноразовый `migrate` применяет Alembic до rollout API; при ошибке новый runtime не запускается. API при старте выполняет только идемпотентный seed: в production он добавляет минимальные справочники и точки, а демонстрационные уловы жёстко отключены `SEED_DEMO_DATA=false`. Одноразовый `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 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-запрос к источнику. Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику.
+117
View File
@@ -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;
+40
View File
@@ -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"
+1 -1
View File
@@ -64,7 +64,7 @@
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog. Аудит выполнен на старой базе `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 сохранены. - [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] **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] **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 не добавлялись. - [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 не добавлялись.
+14 -2
View File
@@ -1,6 +1,6 @@
# Бюджет запросов для альфа-пилота # Бюджет запросов для альфа-пилота
Дата фиксации: 7 сентября 2026 года. Дата фиксации: 13 сентября 2026 года.
## Контракт списочных API ## Контракт списочных API
@@ -31,8 +31,20 @@
Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц. Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой 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 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. После наполнения выполнить `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).