Files
grabowski 1ec5cfb4df
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Build Sphinx Documentation (push) Successful in 15s
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
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 12s
Documentation / Generate API Documentation (push) Successful in 9s
Documentation / Documentation Summary (push) Successful in 2s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s
perf: gzip responses; multi-worker serving with single collection leader
GZipMiddleware (min 500 bytes) compresses the dashboard HTML ~4x and
station JSON up to ~100x, end-to-end through the Caddy TLS terminator —
production load testing showed the deployment is bandwidth-bound once
the response caches hit, so compression is the capacity lever.

WEB_WORKERS (default 2) runs uvicorn multi-process via the app import
string. Every worker executes the lifespan, so a localhost lock port
(COLLECTION_LEADER_PORT, default 8901) elects exactly one
background-collection leader per machine — RID/HII polling stays
once-per-cycle instead of once-per-worker; the lock releases with the
process. Locust clients now send Accept-Encoding so future runs measure
compressed transfer, as browsers do.
2026-08-12 11:34:12 +07:00

99 lines
3.0 KiB
Python

"""Locust load profile for the Ping River Monitor API + dashboard.
Two user types mirror real traffic: dashboard visitors (page + the API calls
the page makes, polling like the auto-refresh does) and API consumers
(direct endpoint hits, including heavy history queries).
Full stress against a LOCAL instance (never full-stress production — it hosts
live flood monitoring):
# separate shell: python -m uvicorn src.web_api:app --port 8125
.venv/Scripts/python.exe -m locust -f scripts/locustfile.py \
--host http://localhost:8125 --headless \
--users 200 --spawn-rate 20 --run-time 2m \
--html load-report.html
Interactive UI instead: drop --headless and open http://localhost:8089.
"""
import random
from locust import FastHttpUser, between, task
# Explicit so measurements reflect compressed transfer (browsers always send this)
GZIP = {"Accept-Encoding": "gzip, deflate"}
class DashboardVisitor(FastHttpUser):
"""A browser session: initial page load, then periodic refresh polling."""
weight = 3
wait_time = between(2, 6)
def on_start(self):
# What one real page load requests
self.client.get("/", headers=GZIP)
self.client.get("/stations", headers=GZIP)
self.client.get("/measurements/latest?limit=500", headers=GZIP)
self.client.get("/api/hii/waterlevel/latest", headers=GZIP)
self.client.get("/api/hii/rainfall/latest", headers=GZIP)
@task(4)
def poll_latest(self):
self.client.get("/measurements/latest?limit=500", headers=GZIP)
@task(2)
def poll_forecast(self):
self.client.get("/forecast", headers=GZIP)
@task(2)
def poll_rain(self):
self.client.get("/api/hii/rainfall/latest", headers=GZIP)
@task(1)
def view_history(self):
station = random.choice(["P.1", "P.67", "P.103", "P.75", "P.20"])
hours = random.choice([24, 168, 720])
self.client.get(
f"/measurements/history/{station}?hours={hours}",
headers=GZIP,
name="/measurements/history/[station]",
)
@task(1)
def stats(self):
self.client.get("/api/stats", headers=GZIP)
class ApiConsumer(FastHttpUser):
"""A script/integration hitting the JSON API directly, no think time."""
weight = 1
wait_time = between(0.1, 1)
@task(3)
def latest(self):
self.client.get("/measurements/latest?limit=100", headers=GZIP)
@task(3)
def hii_feeds(self):
self.client.get(random.choice(
["/api/hii/waterlevel/latest", "/api/hii/rainfall/latest"]
), headers=GZIP, name="/api/hii/[feed]/latest")
@task(2)
def forecast(self):
self.client.get("/forecast", headers=GZIP)
@task(2)
def heavy_history(self):
self.client.get(
"/measurements/history/P.1?hours=8760",
headers=GZIP,
name="/measurements/history/P.1 [heavy]",
)
@task(1)
def health(self):
self.client.get("/health", headers=GZIP)