Files
Northern-Thailand-Ping-Rive…/scripts/load_test.py
T
grabowski 7e642555b4 chore: remove the Python service; Python is the training stack only
Phase 4 of docs/RUST_REWRITE.md. Since 2026-10-18 ping-collector (rust/)
collects, forecasts, notifies (ntfy + Matrix) and serves the website/API;
nothing in production ran the Python service code any more.

Removed (~17k lines): the FastAPI app (web_api, schemas, health_check,
metrics, postgres_history, forecast_history, thaiwater), the Python RID
collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix
alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL,
InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/
logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling
(api_parity, collector_parity, e2e_notify, install_collector.sh,
gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files,
sql/, the release workflow and the docs for all of that. Their tests went
with them.

Kept in Python: src/ml (training, evaluation harness, export; predict.py as
the reference the Rust forecaster is tested against), the HII and RID-dam
clients/stores for the archive backfills (their live collectors removed),
the backfill scripts, retrain.sh + timer, forecast_parity.py.

- PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env;
  the stores lost their sqlite/mysql branches (and the 2026-08 column
  migration). The database tests run against a throwaway schema from
  TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the
  Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB).
- The notifier settings moved from src/notify.py into src/ml/export.py;
  flood_config.json regenerates byte-identical.
- tests/test_dashboard.py checks every path the dashboard fetches against
  ping-collector's router.
- Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil,
  schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv
  1.1.1 explicit (it came in through uvicorn[standard]).
- Formatting contract extended to scripts/ and tests/ (reformatted once).
- scripts/install.sh is the one installer (fresh host or update, in place in
  a checkout without touching its ownership): binary, units, uv sync, .env
  defaults, removes the retired water-monitor.service and any old
  --alert-check cron job, dry run + forecast check, restart, readiness wait.
- Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments
  and Rust doc comments updated to the Rust service.
2026-10-18 17:15:07 +07:00

147 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Staged load / client-stress test for the Ping River Monitor API + dashboard.
Simulates a realistic traffic mix (dashboard page loads, the API calls the
dashboard itself makes, heavy history queries, external API consumers) at
increasing concurrency stages, and reports throughput, latency percentiles,
and errors per stage plus the slowest endpoints.
Run against a LOCAL instance for full stress (never full-stress production —
it hosts live flood monitoring):
rust/target/release/ping-collector serve --listen 127.0.0.1:8125 --no-analytics # separate shell
python scripts/load_test.py http://localhost:8125
A gentle production baseline (low, fixed concurrency):
python scripts/load_test.py https://water.buildfor.life --gentle
"""
import argparse
import random
import statistics
import threading
import time
from collections import Counter
import requests
# Weighted endpoint mix: dashboard session + API consumers
ENDPOINTS = [
("/", 10),
("/measurements/latest?limit=500", 20),
("/stations", 10),
("/api/hii/rainfall/latest", 15),
("/api/hii/waterlevel/latest", 15),
("/forecast", 10),
("/api/stats", 5),
("/measurements/history/P.1?hours=168", 10),
("/measurements/history/P.67?hours=720", 5),
("/health", 5),
]
POOL = [endpoint for endpoint, weight in ENDPOINTS for _ in range(weight)]
FULL_STAGES = [(10, 20), (50, 20), (200, 25)] # (clients, seconds)
GENTLE_STAGES = [(3, 15), (8, 15)]
def _worker(base, stop_at, results, errors):
session = requests.Session()
while time.time() < stop_at:
path = random.choice(POOL)
start = time.perf_counter()
try:
response = session.get(f"{base}{path}", timeout=30)
elapsed = time.perf_counter() - start
if response.status_code == 200:
results.append((path, elapsed))
else:
errors.append((path, response.status_code))
except Exception as error:
errors.append((path, type(error).__name__))
def _pct(values, p):
if len(values) >= 100:
return statistics.quantiles(values, n=100)[p - 1]
return max(values)
def run_stage(base, clients, seconds):
results, errors = [], []
stop_at = time.time() + seconds
threads = [
threading.Thread(
target=_worker, args=(base, stop_at, results, errors), daemon=True
)
for _ in range(clients)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=seconds + 35)
latencies = [elapsed for _, elapsed in results]
total = len(results) + len(errors)
print(f"\n== {clients} clients x {seconds}s ==")
print(
f"requests: {total} ok: {len(results)} errors: {len(errors)} "
f"rps: {total / seconds:.1f}"
)
if latencies:
print(
f"latency ms p50: {statistics.median(latencies) * 1000:.0f} "
f"p95: {_pct(latencies, 95) * 1000:.0f} "
f"p99: {_pct(latencies, 99) * 1000:.0f} "
f"max: {max(latencies) * 1000:.0f}"
)
by_endpoint = {}
for path, elapsed in results:
by_endpoint.setdefault(path, []).append(elapsed)
slowest = sorted(by_endpoint.items(), key=lambda kv: -statistics.median(kv[1]))[:4]
for path, values in slowest:
print(
f" slow: {path:45} n={len(values):5} "
f"p50={statistics.median(values) * 1000:6.0f}ms "
f"max={max(values) * 1000:7.0f}ms"
)
if errors:
top = Counter(f"{path} {code}" for path, code in errors).most_common(5)
print(f" errors: {top}")
return {"clients": clients, "total": total, "errors": len(errors)}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base", nargs="?", default="http://localhost:8125")
parser.add_argument(
"--gentle",
action="store_true",
help="low fixed concurrency (safe for the production instance)",
)
args = parser.parse_args(argv)
base = args.base.rstrip("/")
# Warm caches first so stage 1 doesn't measure cold-start work
for path in ("/forecast", "/api/stats", "/measurements/latest?limit=500"):
try:
requests.get(f"{base}{path}", timeout=60)
except Exception:
pass
print(f"target: {base} mode: {'gentle' if args.gentle else 'full'}")
stages = GENTLE_STAGES if args.gentle else FULL_STAGES
summary = [run_stage(base, clients, seconds) for clients, seconds in stages]
worst = max(
(stage["errors"] / stage["total"] for stage in summary if stage["total"]),
default=1.0,
)
print(f"\nworst-stage error rate: {worst:.1%}")
return 0 if worst < 0.05 else 1
if __name__ == "__main__":
import sys
sys.exit(main())