diff --git a/backend/services/navi_places/place_cache.py b/backend/services/navi_places/place_cache.py index 2a8f01c..cfb9234 100644 --- a/backend/services/navi_places/place_cache.py +++ b/backend/services/navi_places/place_cache.py @@ -15,9 +15,25 @@ import time DEFAULT_DB_PATH = '/var/lib/navi-backend/place_cache.db' +# Cache entries older than this are treated as a miss so enrichment changes (new +# wiki rewrites, etc.) propagate without a manual truncate. Override with the +# NAVI_PLACE_CACHE_TTL_DAYS env var (days; default 30). +DEFAULT_TTL_DAYS = 30 + _db_conn = None +def _ttl_seconds(): + """Cache entry lifetime in seconds (NAVI_PLACE_CACHE_TTL_DAYS, default 30 days).""" + raw = os.environ.get('NAVI_PLACE_CACHE_TTL_DAYS') + if raw is None: + return DEFAULT_TTL_DAYS * 86400 + try: + return float(raw) * 86400 + except ValueError: + return DEFAULT_TTL_DAYS * 86400 + + def db_path(): return os.environ.get('NAVI_PLACE_CACHE_DB', DEFAULT_DB_PATH) @@ -56,6 +72,12 @@ def get_conn(): call_count INTEGER NOT NULL DEFAULT 0 ) """) + # Idempotent TTL-column guard for any legacy DB predating cached_at (the CREATE + # above always includes it, so this only fires on an older on-disk DB). Existing + # rows get cached_at=0 -> treated as expired -> refreshed on next access. + cols = {r[1] for r in _db_conn.execute("PRAGMA table_info(place_cache)")} + if 'cached_at' not in cols: + _db_conn.execute("ALTER TABLE place_cache ADD COLUMN cached_at INTEGER DEFAULT 0") _db_conn.commit() return _db_conn @@ -72,20 +94,29 @@ def reset_cache(): def cache_get(osm_type, osm_id): - """Return cached place dict or None.""" + """Return a cached place dict, or None on miss / TTL expiry. + + A hit older than the TTL (NAVI_PLACE_CACHE_TTL_DAYS, default 30 days) is treated + as a miss so the caller refetches + re-enriches; the row is left in place and the + rewrite (cache_put) overwrites it. Entries with an unknown age (cached_at 0/NULL, + e.g. legacy rows) are likewise treated as expired. + """ db = get_conn() row = db.execute( - "SELECT data FROM place_cache WHERE osm_type=? AND osm_id=?", + "SELECT data, cached_at FROM place_cache WHERE osm_type=? AND osm_id=?", (osm_type, osm_id) ).fetchone() - if row and row[0]: - try: - result = json.loads(row[0]) - result['source'] = 'cache' - return result - except (json.JSONDecodeError, TypeError): - pass - return None + if not row or not row[0]: + return None + cached_at = row[1] + if not cached_at or (time.time() - cached_at) > _ttl_seconds(): + return None + try: + result = json.loads(row[0]) + result['source'] = 'cache' + return result + except (json.JSONDecodeError, TypeError): + return None def cache_put(osm_type, osm_id, data, source): diff --git a/backend/services/navi_places/tests/test_place.py b/backend/services/navi_places/tests/test_place.py index ddd7813..7d6c487 100644 --- a/backend/services/navi_places/tests/test_place.py +++ b/backend/services/navi_places/tests/test_place.py @@ -301,3 +301,48 @@ def test_wiki_enrich_via_local_db_merges_fields(tmp_path, monkeypatch): d = client.get('/api/place/W/123').get_json() assert d['wiki_summary'] == 'A city.' assert d['wiki_url'] == 'https://en.wikipedia.org/wiki/Filer' + + +# ── place_cache TTL ── + +def test_cache_hit_within_ttl_no_refetch(client, monkeypatch): + # Fresh entry (cached_at=now) is within the default 30-day TTL -> served from + # cache, no upstream call (FakeHTTP raises if hit). + place_cache.cache_put('N', 555, {'name': 'Fresh', 'extratags': {}}, 'nominatim_local') + monkeypatch.setattr(pd, 'http_requests', FakeHTTP()) # raises if called + d = client.get('/api/place/N/555').get_json() + assert d['name'] == 'Fresh' and d['source'] == 'cache' + + +def test_cache_hit_past_ttl_refetches_and_updates(client, monkeypatch): + import time + # Seed a stale entry and backdate it well past the 30-day TTL. + place_cache.cache_put('W', 123, {'name': 'Stale', 'extratags': {}}, 'nominatim_local') + old = int(time.time()) - 31 * 86400 + conn = place_cache.get_conn() + conn.execute("UPDATE place_cache SET cached_at=? WHERE osm_type='W' AND osm_id=123", (old,)) + conn.commit() + # Past TTL -> treated as a miss -> refetch from (mocked) Nominatim + re-cache. + monkeypatch.setattr(pd, 'http_requests', FakeHTTP(get=lambda url, **kw: FakeResp(200, NOMINATIM_CAFE))) + d = client.get('/api/place/W/123').get_json() + assert d['name'] == 'Test Cafe' # fresh upstream value, not 'Stale' + assert d['source'] == 'nominatim_local' # refetched, not 'cache' + # cached_at refreshed to ~now (no longer the backdated value) + cached_at = conn.execute( + "SELECT cached_at FROM place_cache WHERE osm_type='W' AND osm_id=123").fetchone()[0] + assert cached_at > old and (time.time() - cached_at) < 60 + + +def test_cache_ttl_env_override(client, monkeypatch): + import time + # A 1-day TTL expires an entry aged 2 days -> refetch. + monkeypatch.setenv('NAVI_PLACE_CACHE_TTL_DAYS', '1') + place_cache.cache_put('N', 777, {'name': 'Old', 'extratags': {}}, 'nominatim_local') + conn = place_cache.get_conn() + conn.execute("UPDATE place_cache SET cached_at=? WHERE osm_type='N' AND osm_id=777", + (int(time.time()) - 2 * 86400,)) + conn.commit() + nom = {**NOMINATIM_CAFE, 'osm_type': 'N', 'osm_id': 777} + monkeypatch.setattr(pd, 'http_requests', FakeHTTP(get=lambda url, **kw: FakeResp(200, nom))) + d = client.get('/api/place/N/777').get_json() + assert d['source'] == 'nominatim_local' # expired under the 1-day override