feat: add alpha load-test methodology
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 до повторения минимум три раза на целевом сервере.
|
||||||
Reference in New Issue
Block a user