perf: add reproducible query plan gate
This commit is contained in:
+7
-1
@@ -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-запрос к источнику.
|
||||
|
||||
|
||||
@@ -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;
|
||||
Executable
+40
@@ -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"
|
||||
Reference in New Issue
Block a user