perf: cache /measurements/latest; stale-on-error fallback for cached endpoints
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
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 24s
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 / Documentation Summary (push) Successful in 2s

The response caches (HII feeds, and now /measurements/latest at 45s TTL
— the endpoint every dashboard poll hits) share one helper,
_ttl_cached_stale: single-flight per key, empty results never cached,
and expired entries kept as a fallback. If a recompute fails (DB
unreachable), the last good response is served with an X-Data-Stale:
true header instead of a 5xx — during an outage the dashboard keeps
showing the last real readings with their honest timestamps. TTLs:
LATEST_CACHE_TTL_SECONDS (45), HII_CACHE_TTL_SECONDS (120).
This commit is contained in:
2026-08-12 11:05:02 +07:00
parent d27ca8bf40
commit 039af8caac
3 changed files with 171 additions and 50 deletions
+2
View File
@@ -88,6 +88,8 @@ class Config:
HII_BASIN_CODE = int(os.getenv("HII_BASIN_CODE", "6")) # 6 = Ping Basin HII_BASIN_CODE = int(os.getenv("HII_BASIN_CODE", "6")) # 6 = Ping Basin
# TTL for the /api/hii/*/latest response cache; source data changes hourly # TTL for the /api/hii/*/latest response cache; source data changes hourly
HII_CACHE_TTL_SECONDS = int(os.getenv("HII_CACHE_TTL_SECONDS", "120")) HII_CACHE_TTL_SECONDS = int(os.getenv("HII_CACHE_TTL_SECONDS", "120"))
# TTL for the /measurements/latest response cache (hottest endpoint)
LATEST_CACHE_TTL_SECONDS = int(os.getenv("LATEST_CACHE_TTL_SECONDS", "45"))
# Umami analytics (self-hosted). The website id is public (it ships in the # Umami analytics (self-hosted). The website id is public (it ships in the
# dashboard <script> tag); server-side API tracking posts to /api/send. # dashboard <script> tag); server-side API tracking posts to /api/send.
+90 -33
View File
@@ -14,7 +14,15 @@ from threading import Lock
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import requests import requests
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query from fastapi import (
BackgroundTasks,
Depends,
FastAPI,
Header,
HTTPException,
Query,
Response,
)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -641,39 +649,68 @@ def _hii_rows(sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
return rows return rows
# Short-TTL cache for the HII feeds: the collector writes hourly, but these # Short-TTL response caches with stale-on-error. Process-local by design
# endpoints carried ~30% of the load-test mix at 0.6-0.9 s per uncached query. # (single-worker deployment); _ttl_cached_stale is the seam where a shared
# Process-local by design (single-worker deployment); the helper is the seam # backend (e.g. Redis) would slot in if we ever run multiple workers.
# where a shared backend (e.g. Redis) would slot in if we ever run multiple # Thread-based single-flight keeps it event-loop-agnostic. Expired entries are
# workers. Thread-based single-flight keeps it event-loop-agnostic. # kept as a fallback: if the recompute fails (typically the DB briefly
# unreachable), the last good response is served instead of a 5xx — during a
# flood, slightly stale readings with a visible timestamp beat an error page.
HII_CACHE: Dict[str, Any] = {} HII_CACHE: Dict[str, Any] = {}
HII_CACHE_LOCK = Lock() HII_CACHE_LOCK = Lock()
_HII_COMPUTE_LOCKS = {"rain": Lock(), "waterlevel": Lock()} _HII_COMPUTE_LOCKS = {"rain": Lock(), "waterlevel": Lock()}
LATEST_CACHE: Dict[str, Any] = {}
LATEST_CACHE_LOCK = Lock()
_LATEST_COMPUTE_LOCK = Lock()
def _hii_cached(feed: str, hours: int, sql: str) -> List[Dict[str, Any]]: def _ttl_cached_stale(cache, cache_lock, compute_lock, key, ttl, compute):
key = f"{feed}:{hours}" """Return (value, is_stale). Single-flight; keeps expired entries as an
ttl = Config.HII_CACHE_TTL_SECONDS error fallback; never caches empty results so recovery is immediate."""
now = time.monotonic() with cache_lock:
with HII_CACHE_LOCK: entry = cache.get(key)
cached = HII_CACHE.get(key) if entry and time.monotonic() - entry[0] < ttl:
if cached and now - cached[0] < ttl: return entry[1], False
return cached[1] with compute_lock:
with _HII_COMPUTE_LOCKS[feed]: # single-flight per feed with cache_lock:
with HII_CACHE_LOCK: entry = cache.get(key)
cached = HII_CACHE.get(key) if entry and time.monotonic() - entry[0] < ttl:
if cached and time.monotonic() - cached[0] < ttl: return entry[1], False
return cached[1] try:
value = compute()
except Exception as error:
if entry is not None:
age = int(time.monotonic() - entry[0])
logger.warning(
f"{key}: recompute failed ({error}); serving {age}s-stale copy"
)
return entry[1], True
raise
if value:
with cache_lock:
cache[key] = (time.monotonic(), value)
return value, False
def _hii_cached(feed: str, hours: int, sql: str):
def compute():
cutoff = datetime.now() - timedelta(hours=hours) cutoff = datetime.now() - timedelta(hours=hours)
rows = _hii_rows(sql, {"cutoff": cutoff}) return _hii_rows(sql, {"cutoff": cutoff})
if rows: # never cache empty: recover immediately once data appears
with HII_CACHE_LOCK: return _ttl_cached_stale(
HII_CACHE[key] = (time.monotonic(), rows) HII_CACHE,
return rows HII_CACHE_LOCK,
_HII_COMPUTE_LOCKS[feed],
f"{feed}:{hours}",
Config.HII_CACHE_TTL_SECONDS,
compute,
)
@app.get("/api/hii/rainfall/latest") @app.get("/api/hii/rainfall/latest")
async def get_hii_rainfall_latest(hours: int = Query(26, ge=1, le=168)): async def get_hii_rainfall_latest(
response: Response, hours: int = Query(26, ge=1, le=168)
):
"""Latest rainfall reading per HII station (Ping basin, collected hourly).""" """Latest rainfall reading per HII station (Ping basin, collected hourly)."""
increment_counter("api_requests", labels={"endpoint": "hii_rainfall"}) increment_counter("api_requests", labels={"endpoint": "hii_rainfall"})
sql = """ sql = """
@@ -687,11 +724,16 @@ async def get_hii_rainfall_latest(hours: int = Query(26, ge=1, le=168)):
GROUP BY station_id) latest GROUP BY station_id) latest
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
""" """
return await asyncio.to_thread(_hii_cached, "rain", hours, sql) rows, stale = await asyncio.to_thread(_hii_cached, "rain", hours, sql)
if stale:
response.headers["X-Data-Stale"] = "true"
return rows
@app.get("/api/hii/waterlevel/latest") @app.get("/api/hii/waterlevel/latest")
async def get_hii_waterlevel_latest(hours: int = Query(26, ge=1, le=168)): async def get_hii_waterlevel_latest(
response: Response, hours: int = Query(26, ge=1, le=168)
):
"""Latest water-level reading per HII station (Ping basin, m MSL).""" """Latest water-level reading per HII station (Ping basin, m MSL)."""
increment_counter("api_requests", labels={"endpoint": "hii_waterlevel"}) increment_counter("api_requests", labels={"endpoint": "hii_waterlevel"})
sql = """ sql = """
@@ -707,7 +749,10 @@ async def get_hii_waterlevel_latest(hours: int = Query(26, ge=1, le=168)):
GROUP BY station_id) latest GROUP BY station_id) latest
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
""" """
return await asyncio.to_thread(_hii_cached, "waterlevel", hours, sql) rows, stale = await asyncio.to_thread(_hii_cached, "waterlevel", hours, sql)
if stale:
response.headers["X-Data-Stale"] = "true"
return rows
@app.get("/measurements/history/{station_code}") @app.get("/measurements/history/{station_code}")
@@ -807,7 +852,7 @@ async def _compute_forecasts(get_latest_forecasts):
@app.get("/measurements/latest", response_model=List[MeasurementResponse]) @app.get("/measurements/latest", response_model=List[MeasurementResponse])
async def get_latest_measurements(limit: int = 100): async def get_latest_measurements(response: Response, limit: int = 100):
"""Get latest measurements from all stations""" """Get latest measurements from all stations"""
increment_counter("api_requests", labels={"endpoint": "measurements_latest"}) increment_counter("api_requests", labels={"endpoint": "measurements_latest"})
@@ -815,11 +860,23 @@ async def get_latest_measurements(limit: int = 100):
if not scraper or not scraper.db_adapter: if not scraper or not scraper.db_adapter:
raise HTTPException(status_code=503, detail="Database not available") raise HTTPException(status_code=503, detail="Database not available")
try: def compute():
# In a thread: this is a synchronous DB query, and this is the most return scraper.get_latest_data(limit=limit)
# frequently hit endpoint — inline it would block the event loop.
measurements = await asyncio.to_thread(scraper.get_latest_data, limit)
try:
# Cached + single-flight in a thread: this is the most frequently hit
# endpoint (every dashboard poll); the source data changes hourly.
measurements, stale = await asyncio.to_thread(
_ttl_cached_stale,
LATEST_CACHE,
LATEST_CACHE_LOCK,
_LATEST_COMPUTE_LOCK,
f"latest:{limit}",
Config.LATEST_CACHE_TTL_SECONDS,
compute,
)
if stale:
response.headers["X-Data-Stale"] = "true"
return [_to_measurement_response(m) for m in measurements] return [_to_measurement_response(m) for m in measurements]
except Exception as e: except Exception as e:
+79 -17
View File
@@ -298,19 +298,27 @@ class TestHiiApiEndpoints:
web_api.HII_CACHE.clear() # response cache would leak across tests web_api.HII_CACHE.clear() # response cache would leak across tests
return web_api return web_api
def test_rainfall_latest(self, web_api): @staticmethod
def _get(web_api_module, endpoint, **kwargs):
import asyncio import asyncio
rows = asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) from fastapi import Response
response = Response()
rows = asyncio.run(
getattr(web_api_module, endpoint)(response=response, **kwargs)
)
return rows, response
def test_rainfall_latest(self, web_api):
rows, _ = self._get(web_api, "get_hii_rainfall_latest", hours=26)
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["oldcode"] == "CHM005" assert rows[0]["oldcode"] == "CHM005"
assert rows[0]["rain_24h"] == 49.6 assert rows[0]["rain_24h"] == 49.6
assert rows[0]["latitude"] == pytest.approx(19.12207) assert rows[0]["latitude"] == pytest.approx(19.12207)
def test_waterlevel_latest(self, web_api): def test_waterlevel_latest(self, web_api):
import asyncio rows, _ = self._get(web_api, "get_hii_waterlevel_latest", hours=26)
rows = asyncio.run(web_api.get_hii_waterlevel_latest(hours=26))
assert len(rows) == 2 assert len(rows) == 2
p1 = next(r for r in rows if r["oldcode"] == "P.1") p1 = next(r for r in rows if r["oldcode"] == "P.1")
assert p1["rid_code"] == "P.1" assert p1["rid_code"] == "P.1"
@@ -319,18 +327,14 @@ class TestHiiApiEndpoints:
assert p1["situation_level"] == 4 assert p1["situation_level"] == 4
def test_empty_when_collector_disabled(self, monkeypatch): def test_empty_when_collector_disabled(self, monkeypatch):
import asyncio
from src import web_api from src import web_api
monkeypatch.setitem(web_api.app_state, "hii_collector", None) monkeypatch.setitem(web_api.app_state, "hii_collector", None)
web_api.HII_CACHE.clear() web_api.HII_CACHE.clear()
assert asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) == [] assert self._get(web_api, "get_hii_rainfall_latest", hours=26)[0] == []
assert asyncio.run(web_api.get_hii_waterlevel_latest(hours=26)) == [] assert self._get(web_api, "get_hii_waterlevel_latest", hours=26)[0] == []
def test_latest_responses_are_cached(self, web_api, monkeypatch): def test_latest_responses_are_cached(self, web_api, monkeypatch):
import asyncio
calls = {"n": 0} calls = {"n": 0}
real = web_api._hii_rows real = web_api._hii_rows
@@ -339,17 +343,33 @@ class TestHiiApiEndpoints:
return real(sql, params) return real(sql, params)
monkeypatch.setattr(web_api, "_hii_rows", counting) monkeypatch.setattr(web_api, "_hii_rows", counting)
first = asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) first, _ = self._get(web_api, "get_hii_rainfall_latest", hours=26)
second = asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) second, _ = self._get(web_api, "get_hii_rainfall_latest", hours=26)
assert first == second and len(first) == 1 assert first == second and len(first) == 1
assert calls["n"] == 1 # second call served from the TTL cache assert calls["n"] == 1 # second call served from the TTL cache
# different hours -> different cache key -> fresh query # different hours -> different cache key -> fresh query
asyncio.run(web_api.get_hii_rainfall_latest(hours=48)) self._get(web_api, "get_hii_rainfall_latest", hours=48)
assert calls["n"] == 2 assert calls["n"] == 2
def test_empty_results_are_not_cached(self, tmp_path, monkeypatch): def test_stale_served_on_recompute_failure(self, web_api, monkeypatch):
import asyncio # Prime the cache, expire it, break the DB: the stale copy is served
# and flagged via the X-Data-Stale header.
good, response = self._get(web_api, "get_hii_rainfall_latest", hours=26)
assert good and "x-data-stale" not in response.headers
from src.config import Config
monkeypatch.setattr(Config, "HII_CACHE_TTL_SECONDS", 0)
def broken(sql, params):
raise RuntimeError("db unreachable")
monkeypatch.setattr(web_api, "_hii_rows", broken)
rows, response = self._get(web_api, "get_hii_rainfall_latest", hours=26)
assert rows == good
assert response.headers["X-Data-Stale"] == "true"
def test_empty_results_are_not_cached(self, tmp_path, monkeypatch):
from src import web_api from src import web_api
from src.hii_collector import HiiCollector from src.hii_collector import HiiCollector
@@ -358,9 +378,51 @@ class TestHiiApiEndpoints:
) )
monkeypatch.setitem(web_api.app_state, "hii_collector", collector) monkeypatch.setitem(web_api.app_state, "hii_collector", collector)
web_api.HII_CACHE.clear() web_api.HII_CACHE.clear()
assert asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) == [] assert self._get(web_api, "get_hii_rainfall_latest", hours=26)[0] == []
assert web_api.HII_CACHE == {} # empty response left uncached assert web_api.HII_CACHE == {} # empty response left uncached
def test_measurements_latest_cached_and_stale(self, monkeypatch):
from types import SimpleNamespace
from src import web_api
from src.config import Config
calls = {"n": 0}
def get_latest_data(limit=100):
calls["n"] += 1
return [
{
"timestamp": "2026-08-12 10:00:00",
"station_code": "P.1",
"station_name_en": "Nawarat Bridge",
"station_name_th": "สะพานนวรัฐ",
"water_level": 2.76,
"discharge": 331.0,
"discharge_percent": 77.9,
}
]
scraper = SimpleNamespace(db_adapter=True, get_latest_data=get_latest_data)
monkeypatch.setitem(web_api.app_state, "scraper", scraper)
web_api.LATEST_CACHE.clear()
rows, response = self._get(web_api, "get_latest_measurements", limit=500)
rows2, _ = self._get(web_api, "get_latest_measurements", limit=500)
assert calls["n"] == 1 # second call cached
assert rows2[0].station_code == "P.1"
# DB failure after expiry -> stale copy + header
monkeypatch.setattr(Config, "LATEST_CACHE_TTL_SECONDS", 0)
def broken(limit=100):
raise RuntimeError("db unreachable")
scraper.get_latest_data = broken
rows3, response = self._get(web_api, "get_latest_measurements", limit=500)
assert rows3[0].water_level == 2.76
assert response.headers["X-Data-Stale"] == "true"
class TestBackfillHelpers: class TestBackfillHelpers:
def test_chunk_date_range(self): def test_chunk_date_range(self):