perf: 120s TTL cache with single-flight on /api/hii/*/latest
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 59s
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
Documentation / Generate API Documentation (push) Successful in 17s
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 24s
Documentation / Validate Documentation (push) Failing after 15s
Documentation / Build Sphinx Documentation (push) Successful in 26s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 2s
Documentation / Documentation Summary (push) Successful in 5s

These endpoints ran an uncached latest-per-station aggregation on every
request (p50 0.6-0.9s under load, ~30% of the traffic mix) for data the
collector refreshes hourly. Responses are now cached per (feed, hours)
for HII_CACHE_TTL_SECONDS (default 120) with a per-feed compute lock so
a cache miss runs one query regardless of concurrency; empty results are
never cached so recovery is immediate. Cached hits measure ~8ms. Cache
is process-local behind a single helper — the seam where a shared
backend (Redis) would slot in if the deployment ever moves to multiple
workers; not warranted at one.
This commit is contained in:
2026-08-12 10:58:19 +07:00
parent 0005f7dce1
commit d27ca8bf40
3 changed files with 70 additions and 4 deletions
+35
View File
@@ -295,6 +295,7 @@ class TestHiiApiEndpoints:
assert collector.store.save_rain(rain) == 1
assert collector.store.save_waterlevel(wl) == 2
monkeypatch.setitem(web_api.app_state, "hii_collector", collector)
web_api.HII_CACHE.clear() # response cache would leak across tests
return web_api
def test_rainfall_latest(self, web_api):
@@ -323,9 +324,43 @@ class TestHiiApiEndpoints:
from src import web_api
monkeypatch.setitem(web_api.app_state, "hii_collector", None)
web_api.HII_CACHE.clear()
assert asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) == []
assert asyncio.run(web_api.get_hii_waterlevel_latest(hours=26)) == []
def test_latest_responses_are_cached(self, web_api, monkeypatch):
import asyncio
calls = {"n": 0}
real = web_api._hii_rows
def counting(sql, params):
calls["n"] += 1
return real(sql, params)
monkeypatch.setattr(web_api, "_hii_rows", counting)
first = asyncio.run(web_api.get_hii_rainfall_latest(hours=26))
second = asyncio.run(web_api.get_hii_rainfall_latest(hours=26))
assert first == second and len(first) == 1
assert calls["n"] == 1 # second call served from the TTL cache
# different hours -> different cache key -> fresh query
asyncio.run(web_api.get_hii_rainfall_latest(hours=48))
assert calls["n"] == 2
def test_empty_results_are_not_cached(self, tmp_path, monkeypatch):
import asyncio
from src import web_api
from src.hii_collector import HiiCollector
collector = HiiCollector(
{"type": "sqlite", "connection_string": f"sqlite:///{tmp_path}/empty.db"}
)
monkeypatch.setitem(web_api.app_state, "hii_collector", collector)
web_api.HII_CACHE.clear()
assert asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) == []
assert web_api.HII_CACHE == {} # empty response left uncached
class TestBackfillHelpers:
def test_chunk_date_range(self):