navi-places: place_cache TTL (default 30 days) (#33)

When PR #30 (wikivoyage name-based discovery) landed, every place cached before
the fix kept returning stale (no-wikivoyage) responses until a manual TRUNCATE of
place_cache. A TTL makes enrichment changes propagate automatically.

- place_cache.py: cache_get now treats a hit older than the TTL as a miss, so the
  caller refetches + re-enriches and cache_put overwrites the row (no delete on
  read). TTL is NAVI_PLACE_CACHE_TTL_DAYS (default 30), via _ttl_seconds(). Entries
  with unknown age (cached_at 0/NULL, e.g. legacy rows) are treated as expired.
- No column migration needed: the schema already has cached_at INTEGER NOT NULL and
  cache_put already writes now(). Added an idempotent guard in get_conn anyway
  (PRAGMA table_info check -> ALTER TABLE ADD COLUMN cached_at INTEGER DEFAULT 0)
  so a hypothetical legacy on-disk DB predating the column self-heals; on the live
  DB and fresh DBs it is a no-op since CREATE TABLE already includes cached_at.

Tests (test_place.py): within-TTL hit served from cache (no refetch); past-TTL hit
refetches + refreshes cached_at; NAVI_PLACE_CACHE_TTL_DAYS=1 override expires a
2-day-old entry. Full navi-places suite: 21 passed.

Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-26 16:53:25 -06:00 committed by GitHub
commit 3c00b69a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 86 additions and 10 deletions

View file

@ -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,19 +94,28 @@ 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]:
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):
pass
return None

View file

@ -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