@@ -42,6 +42,40 @@ def is_limited(self, key: str) -> bool:
4242) # 6 searches per min per IP
4343
4444
45+ # ── In-memory coverage cache for recent (non-historical) queries ──────
46+
47+ _COVERAGE_TTL = 5 * 60 # 5 minutes — matches frontend rounding interval
48+ _COVERAGE_MAX = 20 # max entries before evicting oldest
49+
50+ # key = (since_iso, until_iso, source|None) -> (expires_at_monotonic, trails)
51+ _coverage_cache : dict [tuple , tuple [float , list [dict ]]] = {}
52+
53+
54+ def _coverage_cache_get (
55+ since : datetime , until : datetime , source : str | None
56+ ) -> list [dict ] | None :
57+ key = (since .isoformat (), until .isoformat (), source )
58+ entry = _coverage_cache .get (key )
59+ if entry is None :
60+ return None
61+ expires_at , trails = entry
62+ if time .monotonic () > expires_at :
63+ del _coverage_cache [key ]
64+ return None
65+ return trails
66+
67+
68+ def _coverage_cache_put (
69+ since : datetime , until : datetime , source : str | None , trails : list [dict ]
70+ ) -> None :
71+ key = (since .isoformat (), until .isoformat (), source )
72+ # Evict oldest if full
73+ if len (_coverage_cache ) >= _COVERAGE_MAX and key not in _coverage_cache :
74+ oldest_key = min (_coverage_cache , key = lambda k : _coverage_cache [k ][0 ])
75+ del _coverage_cache [oldest_key ]
76+ _coverage_cache [key ] = (time .monotonic () + _COVERAGE_TTL , trails )
77+
78+
4579# ── Nominatim search proxy with in-memory cache ──────
4680
4781NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
@@ -334,16 +368,21 @@ def get_coverage(
334368 if until is None :
335369 until = now
336370
337- # Check file cache (only hits for fully-historical queries without source filter)
338- if source is None :
339- cached = cache .get (since , until )
340- if cached is not None :
341- trails = cached
342- else :
343- trails = db .get_coverage_trails (since = since , until = until )
344- cache .put (since , until , trails )
345- else :
346- trails = db .get_coverage_trails (since = since , until = until , source = source )
371+ # 1. In-memory cache (short TTL, works for recent/live queries)
372+ trails = _coverage_cache_get (since , until , source )
373+ if trails is None :
374+ # 2. File cache (historical queries only, no source filter)
375+ if source is None :
376+ trails = cache .get (since , until )
377+ if trails is None :
378+ trails = db .get_coverage_trails (
379+ since = since , until = until , ** ({"source" : source } if source else {})
380+ )
381+ # Populate file cache for historical queries
382+ if source is None :
383+ cache .put (since , until , trails )
384+ # Populate in-memory cache for all queries
385+ _coverage_cache_put (since , until , source , trails )
347386
348387 features = [
349388 CoverageFeature (
0 commit comments