From 1ec5cfb4df6f3e4a777080582a6e295be36eac8e Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 12 Aug 2026 11:34:12 +0700 Subject: [PATCH] perf: gzip responses; multi-worker serving with single collection leader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/locustfile.py | 31 +++++++++++++++++------------- src/config.py | 5 +++++ src/main.py | 21 +++++++++++++++------ src/web_api.py | 44 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/scripts/locustfile.py b/scripts/locustfile.py index 20a0bb1..5bb8230 100644 --- a/scripts/locustfile.py +++ b/scripts/locustfile.py @@ -20,6 +20,9 @@ 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.""" @@ -29,23 +32,23 @@ class DashboardVisitor(FastHttpUser): def on_start(self): # What one real page load requests - self.client.get("/") - self.client.get("/stations") - self.client.get("/measurements/latest?limit=500") - self.client.get("/api/hii/waterlevel/latest") - self.client.get("/api/hii/rainfall/latest") + 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") + self.client.get("/measurements/latest?limit=500", headers=GZIP) @task(2) def poll_forecast(self): - self.client.get("/forecast") + self.client.get("/forecast", headers=GZIP) @task(2) def poll_rain(self): - self.client.get("/api/hii/rainfall/latest") + self.client.get("/api/hii/rainfall/latest", headers=GZIP) @task(1) def view_history(self): @@ -53,12 +56,13 @@ class DashboardVisitor(FastHttpUser): 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") + self.client.get("/api/stats", headers=GZIP) class ApiConsumer(FastHttpUser): @@ -69,25 +73,26 @@ class ApiConsumer(FastHttpUser): @task(3) def latest(self): - self.client.get("/measurements/latest?limit=100") + 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"] - ), name="/api/hii/[feed]/latest") + ), headers=GZIP, name="/api/hii/[feed]/latest") @task(2) def forecast(self): - self.client.get("/forecast") + 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") + self.client.get("/health", headers=GZIP) diff --git a/src/config.py b/src/config.py index 7f78b88..35f82d9 100644 --- a/src/config.py +++ b/src/config.py @@ -91,6 +91,11 @@ class Config: # TTL for the /measurements/latest response cache (hottest endpoint) LATEST_CACHE_TTL_SECONDS = int(os.getenv("LATEST_CACHE_TTL_SECONDS", "45")) + # Web server worker processes. Above 1, uvicorn forks workers and a + # localhost lock port elects a single background-collection leader. + WEB_WORKERS = int(os.getenv("WEB_WORKERS", "2")) + COLLECTION_LEADER_PORT = int(os.getenv("COLLECTION_LEADER_PORT", "8901")) + # Umami analytics (self-hosted). The website id is public (it ships in the # dashboard