perf: add reproducible query plan gate
This commit is contained in:
@@ -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
@@ -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-запрос к источнику.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
+1
-1
@@ -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 не добавлялись.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user