Skip to content

Commit f86ba83

Browse files
committed
be nicer to system memory
1 parent 6e8194b commit f86ba83

3 files changed

Lines changed: 72 additions & 36 deletions

File tree

src/where_the_plow/cache.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# src/where_the_plow/cache.py
2-
"""Disk cache for coverage trail responses.
2+
"""Disk cache for JSON responses (coverage trails, search results, etc.).
33
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.
4+
Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of the
5+
request parameters. Each entry carries an absolute expiry time.
66
Uses LRU-style eviction by file access time when total cache size
77
exceeds a budget.
88
"""
@@ -79,7 +79,9 @@ def _delete_if_expired(path: Path, expires_at: float) -> bool:
7979
return True
8080

8181

82-
def get(since: datetime, until: datetime, source: str | None = None) -> list[dict] | None:
82+
def get(
83+
since: datetime, until: datetime, source: str | None = None
84+
) -> list[dict] | None:
8385
"""Return cached trails or None."""
8486
path = CACHE_DIR / f"{_cache_key(since, until, source)}.json"
8587
if not path.exists():
@@ -99,7 +101,9 @@ def get(since: datetime, until: datetime, source: str | None = None) -> list[dic
99101
return None
100102

101103

102-
def put(since: datetime, until: datetime, trails: list[dict], source: str | None = None):
104+
def put(
105+
since: datetime, until: datetime, trails: list[dict], source: str | None = None
106+
):
103107
"""Store trails in disk cache with endpoint-specific TTL policy."""
104108
_ensure_dir()
105109
_evict_if_needed()
@@ -114,3 +118,48 @@ def put(since: datetime, until: datetime, trails: list[dict], source: str | None
114118
logger.debug("cache put: %s (%d trails, ttl=%ds)", path.name, len(trails), ttl)
115119
except OSError:
116120
pass
121+
122+
123+
# ── Search cache (Nominatim geocoding results) ───────
124+
125+
SEARCH_CACHE_TTL = 86400 # 24 hours — addresses don't change often
126+
127+
128+
def _search_key(query: str) -> str:
129+
return "search_" + hashlib.sha256(query.encode()).hexdigest()[:24]
130+
131+
132+
def search_get(query: str) -> list[dict] | None:
133+
"""Return cached search results or None."""
134+
path = CACHE_DIR / f"{_search_key(query)}.json"
135+
if not path.exists():
136+
return None
137+
try:
138+
payload = json.loads(path.read_text())
139+
expires_at = float(payload.get("expires_at", 0))
140+
results = payload.get("results")
141+
if not isinstance(results, list):
142+
return None
143+
if _delete_if_expired(path, expires_at):
144+
return None
145+
os.utime(path)
146+
logger.debug("search cache hit: %s", query)
147+
return results
148+
except (OSError, ValueError, json.JSONDecodeError):
149+
return None
150+
151+
152+
def search_put(query: str, results: list[dict]) -> None:
153+
"""Store search results on disk."""
154+
_ensure_dir()
155+
_evict_if_needed()
156+
path = CACHE_DIR / f"{_search_key(query)}.json"
157+
payload = {
158+
"expires_at": time.time() + SEARCH_CACHE_TTL,
159+
"results": results,
160+
}
161+
try:
162+
path.write_text(json.dumps(payload))
163+
logger.debug("search cache put: %s (%d results)", query, len(results))
164+
except OSError:
165+
pass

src/where_the_plow/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ def __init__(self, path: str):
1212
self.path = path
1313
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1414
self.conn = duckdb.connect(path)
15+
# Cap memory so DuckDB doesn't claim 80% of system RAM on a small VPS.
16+
self.conn.execute("SET memory_limit = '512MB'")
17+
self.conn.execute("SET threads = 2")
1518

1619
def _cursor(self) -> duckdb.DuckDBPyConnection:
1720
"""Create a thread-local cursor for safe concurrent access."""

src/where_the_plow/routes.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import asyncio
33
import logging
44
import time
5-
from collections import defaultdict
65
from datetime import datetime, timezone, timedelta
76

87
import httpx
@@ -23,15 +22,22 @@ class RateLimiter:
2322
def __init__(self, max_hits: int, window_seconds: int):
2423
self.max_hits = max_hits
2524
self.window = window_seconds
26-
self._hits: dict[str, list[float]] = defaultdict(list)
25+
self._hits: dict[str, list[float]] = {}
2726

2827
def is_limited(self, key: str) -> bool:
2928
now = time.monotonic()
30-
bucket = self._hits[key]
31-
self._hits[key] = [t for t in bucket if now - t < self.window]
32-
if len(self._hits[key]) >= self.max_hits:
29+
bucket = self._hits.get(key)
30+
if bucket is not None:
31+
bucket = [t for t in bucket if now - t < self.window]
32+
if not bucket:
33+
del self._hits[key]
34+
bucket = []
35+
else:
36+
bucket = []
37+
if len(bucket) >= self.max_hits:
3338
return True
34-
self._hits[key].append(now)
39+
bucket.append(now)
40+
self._hits[key] = bucket
3541
return False
3642

3743

@@ -42,39 +48,17 @@ def is_limited(self, key: str) -> bool:
4248
) # 6 searches per min per IP
4349

4450

45-
# ── Nominatim search proxy with in-memory cache ──────
51+
# ── Nominatim search proxy ────────────────────────────
4652

4753
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
4854
NOMINATIM_USER_AGENT = (
4955
"WhereThePlow/1.0 (St. John's snowplow tracker; https://plow.jackharrhy.dev)"
5056
)
5157
ST_JOHNS_VIEWBOX = "-52.85,47.45,-52.55,47.65"
52-
SEARCH_CACHE_TTL = 86400 # 24 hours — addresses don't change often
53-
SEARCH_CACHE_MAX = 500 # max entries before evicting oldest
5458

55-
_search_cache: dict[str, tuple[float, list[dict]]] = {} # key -> (expires_at, results)
5659
_nominatim_last_request: float = 0.0 # monotonic timestamp of last outbound request
5760

5861

59-
def _search_cache_get(key: str) -> list[dict] | None:
60-
entry = _search_cache.get(key)
61-
if entry is None:
62-
return None
63-
expires_at, results = entry
64-
if time.monotonic() > expires_at:
65-
del _search_cache[key]
66-
return None
67-
return results
68-
69-
70-
def _search_cache_put(key: str, results: list[dict]) -> None:
71-
# Evict oldest entries if cache is full
72-
if len(_search_cache) >= SEARCH_CACHE_MAX:
73-
oldest_key = min(_search_cache, key=lambda k: _search_cache[k][0])
74-
del _search_cache[oldest_key]
75-
_search_cache[key] = (time.monotonic() + SEARCH_CACHE_TTL, results)
76-
77-
7862
def _client_ip(request: Request) -> str:
7963
return request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (
8064
request.client.host if request.client else "unknown"
@@ -460,7 +444,7 @@ async def search_address(
460444

461445
cache_key = q.strip().lower()
462446

463-
cached = _search_cache_get(cache_key)
447+
cached = cache.search_get(cache_key)
464448
if cached is not None:
465449
return JSONResponse(content=cached)
466450

@@ -495,7 +479,7 @@ async def search_address(
495479

496480
raw = resp.json()
497481
results = [_format_search_result(r) for r in raw]
498-
_search_cache_put(cache_key, results)
482+
cache.search_put(cache_key, results)
499483
return JSONResponse(content=results)
500484

501485
except httpx.TimeoutException:

0 commit comments

Comments
 (0)