feat: add alpha load-test methodology

This commit is contained in:
ik
2026-09-12 15:58:07 +07:00
parent 883ad2c73b
commit 926182a6cf
4 changed files with 97 additions and 1 deletions
+2
View File
@@ -40,6 +40,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
Для измерений на собственном сервере подготовлен 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.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Small read-only latency probe for alpha sizing; not a capacity benchmark."""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import math
import os
import statistics
import time
import urllib.error
import urllib.request
PUBLIC = {
"activity": "/api/v1/activity?hours=72&limit=20",
"records": "/api/v1/records?limit=50",
}
ADMIN = {
"staging": "/api/v1/admin/external-observations?status=review&limit=50",
"moderation": "/api/v1/admin/catch-reports?status=pending&limit=50",
}
def percentile(values: list[float], percent: float) -> float:
return sorted(values)[max(0, math.ceil(len(values) * percent) - 1)]
def request_once(url: str, token: str | None, timeout: float) -> tuple[float, int]:
headers = {"Accept": "application/json", "User-Agent": "RF4-Spotter-load-smoke/0.1"}
if token:
headers["Authorization"] = f"Bearer {token}"
started = time.perf_counter()
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
response.read()
status = response.status
except urllib.error.HTTPError as exc:
exc.read()
status = exc.code
return (time.perf_counter() - started) * 1000, status
def probe(name: str, url: str, *, token: str | None, requests: int, concurrency: int, timeout: float) -> dict:
request_once(url, token, timeout) # one explicit warm-up excluded from statistics
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
samples = list(pool.map(lambda _: request_once(url, token, timeout), range(requests)))
latencies = [sample[0] for sample in samples]
statuses: dict[str, int] = {}
for _, status in samples:
statuses[str(status)] = statuses.get(str(status), 0) + 1
return {
"scenario": name, "requests": requests, "concurrency": concurrency,
"p50_ms": round(statistics.median(latencies), 2),
"p95_ms": round(percentile(latencies, .95), 2),
"max_ms": round(max(latencies), 2), "statuses": statuses,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Read-only RF4 alpha latency probe")
parser.add_argument("--base-url", required=True)
parser.add_argument("--requests", type=int, default=100)
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--timeout", type=float, default=10)
args = parser.parse_args()
if not 1 <= args.requests <= 10_000 or not 1 <= args.concurrency <= 100:
parser.error("requests must be 1..10000 and concurrency 1..100")
base = args.base_url.rstrip("/")
token = os.environ.get("ADMIN_TOKEN")
results = [probe(name, base + path, token=None, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in PUBLIC.items()]
if token:
results.extend(probe(name, base + path, token=token, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in ADMIN.items())
print(json.dumps({"base_url": base, "admin_scenarios_skipped": not bool(token), "results": results}, ensure_ascii=False, indent=2))
return 1 if any(any(not status.startswith("2") for status in result["statuses"]) for result in results) else 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -42,7 +42,7 @@
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…». - [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером. - [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска. - [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
- [ ] **Q07 · Нагрузочная методика.** Подготовить воспроизводимый сценарий измерения p95 для activity, records, staging и moderation без объявления результатов до запуска на целевом сервере. - [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере.
- [ ] **Q08 · Политика MinIO.** Ограничить app credentials одним bucket и добавить безопасную автоматическую проверку policy; root credentials оставить только bootstrap-задаче. - [ ] **Q08 · Политика MinIO.** Ограничить app credentials одним bucket и добавить безопасную автоматическую проверку policy; root credentials оставить только bootstrap-задаче.
- [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных. - [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных.
- [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog. - [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog.
+14
View File
@@ -0,0 +1,14 @@
# Нагрузочная проверка открытой альфы
Цель — получить воспроизводимый baseline задержки чтения, а не доказать предельную производительность. Проверка выполняется только на принадлежащем проекту staging/production после наполнения реалистичным разрешённым набором данных. Внешние источники парсеров не затрагиваются.
```bash
python3 deploy/load-smoke.py --base-url https://rf4spotter.ru --requests 100 --concurrency 5 > load-baseline.json
ADMIN_TOKEN='секрет' python3 deploy/load-smoke.py --base-url https://rf4spotter.ru --requests 100 --concurrency 5 > load-admin-baseline.json
```
Без `ADMIN_TOKEN` измеряются `activity` и `records`; с токеном добавляются staging и moderation. Скрипт выполняет только GET, делает один исключённый warm-up на сценарий и выводит число ответов по HTTP-кодам, p50, p95 и максимум. Токен не попадает в результат.
Перед запуском зафиксировать ревизию Git, конфигурацию сервера, число строк основных таблиц, concurrency и время UTC. Первый безопасный прогон: 100 запросов при concurrency 5. Повышать до 10/20 только при стабильном error rate 0%, наблюдая CPU, RAM, PostgreSQL connections и дисковый I/O. Между ступенями выдерживать две минуты.
Предварительный бюджет открытой альфы: p95 публичных чтений ≤ 500 мс, admin-очередей ≤ 1000 мс, HTTP 5xx = 0%. Это критерий расследования, а не обещание пользователям. При превышении сначала снять `EXPLAIN (ANALYZE, BUFFERS)` по [query-performance.md](query-performance.md), затем менять запросы/индексы. Результаты не вносить в README до повторения минимум три раза на целевом сервере.