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
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:
+79
-17
@@ -298,19 +298,27 @@ class TestHiiApiEndpoints:
|
||||
web_api.HII_CACHE.clear() # response cache would leak across tests
|
||||
return web_api
|
||||
|
||||
def test_rainfall_latest(self, web_api):
|
||||
@staticmethod
|
||||
def _get(web_api_module, endpoint, **kwargs):
|
||||
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 rows[0]["oldcode"] == "CHM005"
|
||||
assert rows[0]["rain_24h"] == 49.6
|
||||
assert rows[0]["latitude"] == pytest.approx(19.12207)
|
||||
|
||||
def test_waterlevel_latest(self, web_api):
|
||||
import asyncio
|
||||
|
||||
rows = asyncio.run(web_api.get_hii_waterlevel_latest(hours=26))
|
||||
rows, _ = self._get(web_api, "get_hii_waterlevel_latest", hours=26)
|
||||
assert len(rows) == 2
|
||||
p1 = next(r for r in rows if r["oldcode"] == "P.1")
|
||||
assert p1["rid_code"] == "P.1"
|
||||
@@ -319,18 +327,14 @@ class TestHiiApiEndpoints:
|
||||
assert p1["situation_level"] == 4
|
||||
|
||||
def test_empty_when_collector_disabled(self, monkeypatch):
|
||||
import asyncio
|
||||
|
||||
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)) == []
|
||||
assert self._get(web_api, "get_hii_rainfall_latest", hours=26)[0] == []
|
||||
assert self._get(web_api, "get_hii_waterlevel_latest", hours=26)[0] == []
|
||||
|
||||
def test_latest_responses_are_cached(self, web_api, monkeypatch):
|
||||
import asyncio
|
||||
|
||||
calls = {"n": 0}
|
||||
real = web_api._hii_rows
|
||||
|
||||
@@ -339,17 +343,33 @@ class TestHiiApiEndpoints:
|
||||
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))
|
||||
first, _ = self._get(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 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))
|
||||
self._get(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
|
||||
def test_stale_served_on_recompute_failure(self, web_api, monkeypatch):
|
||||
# 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.hii_collector import HiiCollector
|
||||
|
||||
@@ -358,9 +378,51 @@ class TestHiiApiEndpoints:
|
||||
)
|
||||
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 self._get(web_api, "get_hii_rainfall_latest", hours=26)[0] == []
|
||||
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:
|
||||
def test_chunk_date_range(self):
|
||||
|
||||
Reference in New Issue
Block a user