CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 13s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Generate API Documentation (push) Successful in 8s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
Retrained on the gap-filled DB (592k -> 976k rows) and re-examined the flood backtests, now reproducible via scripts/backtest_render.py (renders the three docs/img charts and gates on a >=12h 2024 first-alert lead — currently failing by design and documented as such). Findings, all documented in FLOOD_FORECASTING.md: the true 2024 crossing was 24 Sep 17:00 (8h earlier than recorded; confirmed against the independent HII sensor), the historical 24h-warning claim was partly a missing-data artifact, and retrained warn classifiers collapse on the filled grid (P.1 24h PR-AUC 0.900 -> 0.288) while regression MAE improves (11.3 -> 10.5 cm). Serving therefore becomes max(classifier, sigmoid(regression)) so alerting is never worse than the regression path; metrics table, head-gating tiers, honest-limits and runbook expectations all updated to the current model (hgb-v1+d2d0e65). Perf, from Locust load testing (scripts/locustfile.py + load_test.py): single-flight lock around /forecast inference (concurrent cache misses previously each ran ~18s inference and starved the shared thread pool; 200-user run after: 105 rps, 0.01% errors), and /measurements/latest + /health moved off the event loop (synchronous DB/network calls in async handlers were stalling every request under load).
149 lines
4.8 KiB
Python
149 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):
|
|
|
|
python -m uvicorn src.web_api:app --port 8125 # 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())
|