diff --git a/README.md b/README.md index 0c491c1..e8319df 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: 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 запросов, внутренних ошибок и другой диагностической информации. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). diff --git a/deploy/load-smoke.py b/deploy/load-smoke.py new file mode 100644 index 0000000..4decdb4 --- /dev/null +++ b/deploy/load-smoke.py @@ -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()) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 71ddccf..9b493e4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,7 +42,7 @@ - [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] **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-задаче. - [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных. - [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog. diff --git a/docs/load-testing.md b/docs/load-testing.md new file mode 100644 index 0000000..092c3f6 --- /dev/null +++ b/docs/load-testing.md @@ -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 до повторения минимум три раза на целевом сервере.