thermograph/store.py
Emi Griffith 7358d9eb48 Fix compare place names (Nominatim burst) + weekly duplicate location name (#29)
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.

- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
  Nominatim calls, re-checking the cache under the lock), so concurrent callers
  resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
  null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
  coordinates fallback can't stick for the life of the token.

Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.


Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:03:57 +00:00

172 lines
6.7 KiB
Python

"""Persistent derived-data store — SQLite (WAL) at data/thermograph.sqlite.
The raw per-cell parquet records stay the source of truth; this database holds
only what's *derived* from them, so expensive work is done once per cell and
survives restarts/deploys instead of being recomputed per request, per view:
* ``derived`` — finished API response payloads (grade / calendar / day /
forecast), zlib-compressed JSON keyed by (kind, cell, request key) and
guarded by a validity ``token``. The token encodes everything the payload
depends on (payload schema version, history end date, recent-fetch stamp…);
a row whose stored token doesn't match the caller's current token is simply
a miss, so stale data can never be served — freshness is driven by the token
advancing, never by clock-based expiry.
* ``revgeo`` — reverse-geocode labels ("City, State" per ~cell), previously an
in-memory dict that re-hit Nominatim after every restart.
The store is a pure accelerator: every reader falls back to recomputing from
parquet when the database is missing, locked, or corrupt (all helpers swallow
their own errors), and deleting data/thermograph.sqlite is a safe full reset.
"""
import json
import os
import sqlite3
import threading
import time
import zlib
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "thermograph.sqlite")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS derived (
kind TEXT NOT NULL, -- payload family: grade | calendar | day | forecast
cell_id TEXT NOT NULL,
key TEXT NOT NULL, -- request identity within the kind (dates/spans/params)
token TEXT NOT NULL, -- validity: mismatched token == miss (see module docstring)
payload BLOB NOT NULL, -- zlib-compressed JSON bytes
updated_at REAL NOT NULL,
PRIMARY KEY (kind, cell_id, key)
);
CREATE TABLE IF NOT EXISTS revgeo (
key TEXT PRIMARY KEY, -- "lat,lon" rounded to ~cell precision
label TEXT, -- NULL == lookup ran but found no label
updated_at REAL NOT NULL
);
"""
# SQLite connections aren't shareable across threads; uvicorn serves requests on
# a thread pool, so each thread lazily opens its own connection. WAL lets those
# readers run concurrently with the (serialized) writers.
_local = threading.local()
def _conn() -> sqlite3.Connection | None:
conn = getattr(_local, "conn", None)
if conn is not None:
return conn
try:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=5.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.executescript(_SCHEMA)
_local.conn = conn
return conn
except Exception: # noqa: BLE001 - the store is optional; readers fall back
return None
# --- derived payloads -------------------------------------------------------
def get_payload(kind: str, cell_id: str, key: str, token: str) -> bytes | None:
"""Raw JSON bytes of a cached payload, or None when absent or token-invalid."""
conn = _conn()
if conn is None:
return None
try:
row = conn.execute(
"SELECT token, payload FROM derived WHERE kind=? AND cell_id=? AND key=?",
(kind, cell_id, key),
).fetchone()
if row is None or row[0] != token:
return None
return zlib.decompress(row[1])
except Exception: # noqa: BLE001
return None
def get_json(kind: str, cell_id: str, key: str, token: str) -> dict | None:
"""Like get_payload but decoded — for callers assembling composite responses."""
body = get_payload(kind, cell_id, key, token)
return json.loads(body) if body is not None else None
def put_payload(kind: str, cell_id: str, key: str, token: str, payload: dict,
cache: bool = True) -> bytes:
"""Encode ``payload`` to compact JSON, persist it, and return the encoded bytes
(so the caller can serve the exact same bytes without re-serializing).
Pass ``cache=False`` to serve the payload without persisting it — used when a
best-effort field (the reverse-geocoded place name) failed to resolve, so the
incomplete result isn't cached for the life of the token.
allow_nan=False mirrors Starlette's JSONResponse: a NaN slipping through
grading should fail loudly here, not be cached as JS-unparseable `NaN`."""
body = json.dumps(payload, default=str, allow_nan=False,
separators=(",", ":")).encode()
conn = _conn()
if cache and conn is not None:
try:
conn.execute(
"INSERT OR REPLACE INTO derived VALUES (?,?,?,?,?,?)",
(kind, cell_id, key, token, zlib.compress(body, 6), time.time()),
)
conn.commit()
except Exception: # noqa: BLE001 - failing to cache must not fail the request
pass
return body
# --- reverse-geocode labels --------------------------------------------------
REVGEO_MISS_TTL = 3600.0 # a "no label" result may be transient (rate limit) — retry hourly
def revgeo_key(lat: float, lon: float) -> str:
return f"{round(lat, 3)},{round(lon, 3)}"
def get_revgeo(key: str) -> tuple[bool, str | None]:
"""(found, label). A stored NULL label counts as found (a cached miss) until
it ages past REVGEO_MISS_TTL, when the lookup becomes retryable."""
conn = _conn()
if conn is None:
return (False, None)
try:
row = conn.execute("SELECT label, updated_at FROM revgeo WHERE key=?", (key,)).fetchone()
if row is None:
return (False, None)
if row[0] is None and time.time() - row[1] > REVGEO_MISS_TTL:
return (False, None)
return (True, row[0])
except Exception: # noqa: BLE001
return (False, None)
def put_revgeo(key: str, label: str | None) -> None:
conn = _conn()
if conn is None:
return
try:
conn.execute("INSERT OR REPLACE INTO revgeo VALUES (?,?,?)", (key, label, time.time()))
conn.commit()
except Exception: # noqa: BLE001
pass
def stats() -> dict:
"""Row counts + on-disk size, for the migrate script's summary output."""
conn = _conn()
out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, "bytes": 0}
if conn is None:
return out
try:
out["derived"] = conn.execute("SELECT COUNT(*) FROM derived").fetchone()[0]
out["revgeo"] = conn.execute("SELECT COUNT(*) FROM revgeo").fetchone()[0]
# Recent writes may still live in the WAL sidecar — count both files.
out["bytes"] = sum(os.path.getsize(p) for p in (DB_PATH, DB_PATH + "-wal")
if os.path.exists(p))
except Exception: # noqa: BLE001
pass
return out