diff --git a/src/web_api.py b/src/web_api.py index 0fd1848..f65a03e 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -784,6 +784,8 @@ async def get_health(): "health", Config.HEALTH_CACHE_TTL_SECONDS, compute, + # never report a state older than a few TTLs: monitors poll /health + max_stale=4 * Config.HEALTH_CACHE_TTL_SECONDS, ) return HealthResponse(**summary) @@ -1102,7 +1104,9 @@ _REFRESH_IN_FLIGHT: set = set() _REFRESH_FLAG_LOCK = Lock() -async def _cached_swr(cache, cache_lock, compute_lock, key, ttl, compute): +async def _cached_swr( + cache, cache_lock, compute_lock, key, ttl, compute, max_stale=None +): """Stale-while-revalidate. Returns (value, served_stale). fresh -> return inline; expired-but-present -> return the stale value @@ -1110,13 +1114,19 @@ async def _cached_swr(cache, cache_lock, compute_lock, key, ttl, compute): thread is parked on the single-flight lock — under stampede that parking exhausted the pool and timed out unrelated endpoints); absent -> cold single-flight compute (first request per key since startup). + + ``max_stale`` (seconds) bounds how old a served stale copy may be; older + entries are recomputed inline. A worker that idles overnight would + otherwise answer its first request with a result from the evening before. """ fresh = _cache_fresh(cache, cache_lock, key, ttl) if fresh is not None: return fresh, False with cache_lock: entry = cache.get(key) - if entry is not None: + if entry is not None and ( + max_stale is None or time.monotonic() - entry[0] <= max_stale + ): with _REFRESH_FLAG_LOCK: should_start = key not in _REFRESH_IN_FLIGHT if should_start: diff --git a/tests/test_collection_disabled.py b/tests/test_collection_disabled.py index b785284..76bb1ef 100644 --- a/tests/test_collection_disabled.py +++ b/tests/test_collection_disabled.py @@ -94,3 +94,30 @@ def test_manual_trigger_is_refused_when_collection_runs_elsewhere(monkeypatch): with pytest.raises(HTTPException) as err: asyncio.run(web_api.trigger_scraping(BackgroundTasks())) assert err.value.status_code == 409 + + +def test_stale_cache_older_than_max_stale_is_recomputed(monkeypatch): + """/health must not answer with a result from hours ago.""" + import asyncio + import threading + import time + + from src import web_api + + cache, lock, compute_lock = {}, threading.Lock(), threading.Lock() + cache["k"] = (time.monotonic() - 3600, "an hour old") + + value, stale = asyncio.run( + web_api._cached_swr( + cache, lock, compute_lock, "k", 30, lambda: "fresh", max_stale=120 + ) + ) + assert (value, stale) == ("fresh", False) + + cache["k"] = (time.monotonic() - 60, "a minute old") + value, stale = asyncio.run( + web_api._cached_swr( + cache, lock, compute_lock, "k", 30, lambda: "fresh", max_stale=120 + ) + ) + assert (value, stale) == ("a minute old", True) # SWR within the bound