Skip to content

Commit 6e8194b

Browse files
committed
cache: move coverage cache to disk for all windows/sources
1 parent 6c8d60f commit 6e8194b

2 files changed

Lines changed: 57 additions & 75 deletions

File tree

src/where_the_plow/cache.py

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# src/where_the_plow/cache.py
2-
"""Simple file-based cache for coverage trail responses.
2+
"""Disk cache for coverage trail responses.
33
4-
Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of the
5-
(since, until) time range. Only caches queries whose `until` is
6-
before today (i.e. fully historical, immutable data). Uses LRU
7-
eviction by file access time when total cache size exceeds a budget.
4+
Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of
5+
(since, until, source). Each entry carries an absolute expiry time.
6+
Uses LRU-style eviction by file access time when total cache size
7+
exceeds a budget.
88
"""
99

1010
import hashlib
1111
import json
1212
import logging
1313
import os
1414
import tempfile
15+
import time
1516
from datetime import datetime, timezone
1617
from pathlib import Path
1718

@@ -20,21 +21,29 @@
2021
CACHE_DIR = Path(tempfile.gettempdir()) / "where-the-plow-cache"
2122
MAX_CACHE_BYTES = 200 * 1024 * 1024 # 200 MB
2223

24+
# Cache policy tuned for coverage endpoint
25+
RECENT_TTL_SECONDS = 5 * 60 # 5 minutes for live-ish windows
26+
HISTORICAL_TTL_SECONDS = 24 * 60 * 60 # 1 day for immutable historical windows
2327

24-
def _cache_key(since: datetime, until: datetime) -> str:
25-
raw = f"{since.isoformat()}|{until.isoformat()}"
26-
return hashlib.sha256(raw.encode()).hexdigest()[:16]
2728

29+
def _cache_key(since: datetime, until: datetime, source: str | None) -> str:
30+
raw = f"{since.isoformat()}|{until.isoformat()}|{source or ''}"
31+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
2832

29-
def _is_cacheable(until: datetime) -> bool:
30-
"""Only cache if the entire window is in the past (before today UTC)."""
33+
34+
def _is_historical(until: datetime) -> bool:
35+
"""Return True if window is fully in the past (before today UTC)."""
3136
today_start = datetime.now(timezone.utc).replace(
3237
hour=0, minute=0, second=0, microsecond=0
3338
)
3439
until_utc = until if until.tzinfo else until.replace(tzinfo=timezone.utc)
3540
return until_utc < today_start
3641

3742

43+
def _ttl_for(until: datetime) -> int:
44+
return HISTORICAL_TTL_SECONDS if _is_historical(until) else RECENT_TTL_SECONDS
45+
46+
3847
def _ensure_dir():
3948
CACHE_DIR.mkdir(parents=True, exist_ok=True)
4049

@@ -48,7 +57,6 @@ def _evict_if_needed():
4857
total = sum(f.stat().st_size for f in files)
4958
if total <= MAX_CACHE_BYTES:
5059
return
51-
# Sort by access time, oldest first
5260
files.sort(key=lambda f: f.stat().st_atime)
5361
for f in files:
5462
if total <= MAX_CACHE_BYTES:
@@ -61,32 +69,48 @@ def _evict_if_needed():
6169
pass
6270

6371

64-
def get(since: datetime, until: datetime) -> list[dict] | None:
65-
"""Return cached trails or None if not cached."""
66-
if not _is_cacheable(until):
67-
return None
68-
path = CACHE_DIR / f"{_cache_key(since, until)}.json"
72+
def _delete_if_expired(path: Path, expires_at: float) -> bool:
73+
if time.time() <= expires_at:
74+
return False
75+
try:
76+
path.unlink(missing_ok=True)
77+
except OSError:
78+
pass
79+
return True
80+
81+
82+
def get(since: datetime, until: datetime, source: str | None = None) -> list[dict] | None:
83+
"""Return cached trails or None."""
84+
path = CACHE_DIR / f"{_cache_key(since, until, source)}.json"
6985
if not path.exists():
7086
return None
7187
try:
72-
# Touch access time for LRU
88+
payload = json.loads(path.read_text())
89+
expires_at = float(payload.get("expires_at", 0))
90+
trails = payload.get("trails")
91+
if not isinstance(trails, list):
92+
return None
93+
if _delete_if_expired(path, expires_at):
94+
return None
7395
os.utime(path)
74-
data = json.loads(path.read_text())
7596
logger.debug("cache hit: %s", path.name)
76-
return data
77-
except (OSError, json.JSONDecodeError):
97+
return trails
98+
except (OSError, ValueError, json.JSONDecodeError):
7899
return None
79100

80101

81-
def put(since: datetime, until: datetime, trails: list[dict]):
82-
"""Store trails in cache if the query is cacheable."""
83-
if not _is_cacheable(until):
84-
return
102+
def put(since: datetime, until: datetime, trails: list[dict], source: str | None = None):
103+
"""Store trails in disk cache with endpoint-specific TTL policy."""
85104
_ensure_dir()
86105
_evict_if_needed()
87-
path = CACHE_DIR / f"{_cache_key(since, until)}.json"
106+
path = CACHE_DIR / f"{_cache_key(since, until, source)}.json"
107+
ttl = _ttl_for(until)
108+
payload = {
109+
"expires_at": time.time() + ttl,
110+
"trails": trails,
111+
}
88112
try:
89-
path.write_text(json.dumps(trails))
90-
logger.debug("cache put: %s (%d trails)", path.name, len(trails))
113+
path.write_text(json.dumps(payload))
114+
logger.debug("cache put: %s (%d trails, ttl=%ds)", path.name, len(trails), ttl)
91115
except OSError:
92116
pass

src/where_the_plow/routes.py

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -42,40 +42,6 @@ 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-
7945
# ── Nominatim search proxy with in-memory cache ──────
8046

8147
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
@@ -368,21 +334,13 @@ def get_coverage(
368334
if until is None:
369335
until = now
370336

371-
# 1. In-memory cache (short TTL, works for recent/live queries)
372-
trails = _coverage_cache_get(since, until, source)
337+
# Disk cache for all windows/sources (short TTL for recent, longer for historical)
338+
trails = cache.get(since, until, source=source)
373339
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)
340+
trails = db.get_coverage_trails(
341+
since=since, until=until, **({"source": source} if source else {})
342+
)
343+
cache.put(since, until, trails, source=source)
386344

387345
features = [
388346
CoverageFeature(

0 commit comments

Comments
 (0)