mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
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:
parent
7c10b80d08
commit
3c00b69a55
2 changed files with 86 additions and 10 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue