thermograph/backend/data/store.py
Emi Griffith a4be7066e5 Subtree-merge thermograph-backend (origin/main) into backend/
git-subtree-dir: backend
git-subtree-mainline: 6723fc0326
git-subtree-split: 83c2e05b96
2026-07-22 22:01:11 -07:00

329 lines
14 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.
On Postgres (``THERMOGRAPH_DATABASE_URL=postgresql://…``) the same two tables live
as UNLOGGED tables — fast and non-durable, which suits a rebuildable cache — and
psycopg (via a bounded pool, see _get_pg_pool) replaces sqlite3. Every fail-soft
path is identical: a connection or query that fails just degrades to
recompute-from-parquet.
"""
import contextlib
import json
import os
import sqlite3
import threading
import time
import zlib
import paths
DB_PATH = os.path.join(paths.DATA_DIR, "thermograph.sqlite")
# Dialect selection mirrors accounts/db.py: a ``postgresql://`` URL in
# THERMOGRAPH_DATABASE_URL switches the store to Postgres; anything else keeps the
# SQLite file above. (This module uses the raw driver, not SQLAlchemy, so the DSN
# is a plain libpq URL — the ``+asyncpg`` / ``+psycopg`` SQLAlchemy suffix stripped.)
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
def _libpq_dsn(url: str) -> str:
"""A plain libpq URL that ``psycopg.connect`` accepts: drop the SQLAlchemy
driver suffix (``postgresql+asyncpg://`` / ``+psycopg://`` -> ``postgresql://``)."""
return url.replace("+asyncpg", "").replace("+psycopg", "")
_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""
_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
);
"""
# Postgres mirror of _SCHEMA. UNLOGGED = fast, crash-non-durable (the cache is
# rebuildable, so "delete = safe reset" still holds); BLOB becomes BYTEA and REAL
# becomes DOUBLE PRECISION. psycopg's extended protocol runs one statement per
# execute, so the DDL is a tuple executed in turn (not one script like SQLite).
_PG_SCHEMA = (
"""
CREATE UNLOGGED TABLE IF NOT EXISTS derived (
kind TEXT NOT NULL,
cell_id TEXT NOT NULL,
key TEXT NOT NULL,
token TEXT NOT NULL,
payload BYTEA NOT NULL,
updated_at DOUBLE PRECISION NOT NULL,
PRIMARY KEY (kind, cell_id, key)
)
""",
"""
CREATE UNLOGGED TABLE IF NOT EXISTS revgeo (
key TEXT PRIMARY KEY,
label TEXT,
updated_at DOUBLE PRECISION NOT NULL
)
""",
)
# Statement text differs by dialect (placeholder style + upsert syntax); the SQLite
# strings are byte-for-byte the originals. Postgres uses %s params, an explicit
# column list, and ON CONFLICT ... DO UPDATE — which, like SQLite's INSERT OR
# REPLACE, overwrites *every* non-key column (token included, so a refreshed token
# supersedes the stored one and freshness keeps tracking the token, never the clock).
if IS_POSTGRES:
_SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=%s AND cell_id=%s AND key=%s"
_SQL_PUT_PAYLOAD = (
"INSERT INTO derived (kind, cell_id, key, token, payload, updated_at) "
"VALUES (%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (kind, cell_id, key) DO UPDATE SET "
"token=EXCLUDED.token, payload=EXCLUDED.payload, updated_at=EXCLUDED.updated_at"
)
_SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=%s"
_SQL_PUT_REVGEO = (
"INSERT INTO revgeo (key, label, updated_at) VALUES (%s,%s,%s) "
"ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, updated_at=EXCLUDED.updated_at"
)
else:
_SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=? AND cell_id=? AND key=?"
_SQL_PUT_PAYLOAD = "INSERT OR REPLACE INTO derived VALUES (?,?,?,?,?,?)"
_SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=?"
_SQL_PUT_REVGEO = "INSERT OR REPLACE INTO revgeo VALUES (?,?,?)"
# SQLite connections aren't shareable across threads; uvicorn serves requests on
# a thread pool, so each thread lazily opens (and keeps, for its lifetime) its own
# connection. WAL lets those readers run concurrently with the (serialized)
# writers. There's no server-side connection limit to protect against here, so
# this thread-local model stays as-is for SQLite.
_local = threading.local()
# Postgres instead uses a bounded pool (see _get_pg_pool), not a thread-local
# connection: the old pattern opened one raw psycopg connection per THREAD,
# lazily, and never closed it. Starlette's sync threadpool can spin up dozens of
# worker threads per uvicorn worker; with N workers x this module x
# data/climate_store.py doing the same thing, that had no ceiling and could
# exhaust Postgres max_connections. min_size=1 keeps an idle deploy cheap;
# max_size=8 is a modest per-process ceiling that still lets several concurrent
# requests proceed without serializing on one connection. connect_timeout +
# statement_timeout bound how long a caller (or the notifier's leader-election
# flock) can be wedged by an unresponsive Postgres.
_pg_pool_lock = threading.Lock()
_pg_pool = None # type: "psycopg_pool.ConnectionPool | None"
def _configure_pg(conn) -> None:
"""Run once per NEW physical connection the pool opens (psycopg_pool's
``configure=``), not on every checkout — mirrors the old _open_pg's
connect-then-ensure-schema sequence without re-running the (idempotent)
DDL on every borrow.
Multiple connections can legitimately open around the same moment (the
pool's min_size warm-up racing an immediate caller's on-demand open; two
uvicorn workers' pools both cold-starting at once) and each runs this same
CREATE TABLE IF NOT EXISTS. Despite IF NOT EXISTS, two sessions creating the
same brand-new table at the same instant can still hit a duplicate-key error
on Postgres's catalog — a documented race, not a real failure: it just means
the table exists either way, so it's swallowed here rather than failing this
connection's open (which put_payload/get_payload etc. would otherwise
silently treat as "store unavailable" and drop the operation)."""
import psycopg # local import: only the Postgres path needs it
for stmt in _PG_SCHEMA:
try:
conn.execute(stmt)
except psycopg.errors.UniqueViolation:
pass
def _get_pg_pool():
"""Lazily open (once) the bounded Postgres pool, or None if it can't be
opened — callers treat that exactly like the old _conn() returning None."""
global _pg_pool
if _pg_pool is not None:
return _pg_pool
with _pg_pool_lock:
if _pg_pool is None:
try:
import psycopg_pool # local import: only the Postgres path needs it
_pg_pool = psycopg_pool.ConnectionPool(
_PG_DSN,
min_size=1,
max_size=8,
kwargs={
"autocommit": True,
"connect_timeout": 5,
"options": "-c statement_timeout=30000",
},
configure=_configure_pg,
open=True,
)
except Exception: # noqa: BLE001 - the store is optional; readers fall back
return None
return _pg_pool
@contextlib.contextmanager
def _conn():
"""Yield a working connection, or None when the store is unavailable.
Postgres: borrowed from the bounded pool above for the duration of one
operation and returned automatically on exit. SQLite: the thread's
persistent connection (opened once, kept for the thread's lifetime, same as
before). Either way every caller frames exactly one operation in a
``with _conn() as conn:`` block."""
if IS_POSTGRES:
pool = _get_pg_pool()
if pool is None:
yield None
return
with pool.connection() as conn:
yield conn
return
conn = getattr(_local, "conn", None)
if conn is None:
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
except Exception: # noqa: BLE001 - the store is optional; readers fall back
yield None
return
yield conn
# --- 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."""
try:
with _conn() as conn:
if conn is None:
return None
row = conn.execute(_SQL_GET_PAYLOAD, (kind, cell_id, key)).fetchone()
if row is None or row[0] != token:
return None
# psycopg returns bytea as bytes/memoryview; bytes() normalizes both.
data = bytes(row[1]) if IS_POSTGRES else row[1]
return zlib.decompress(data)
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()
if cache:
try:
with _conn() as conn:
if conn is not None:
conn.execute(
_SQL_PUT_PAYLOAD,
(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:
# The "v2:" prefix versions the label format: v1 rows (no country) are bypassed
# and re-fetched so names pick up the trailing country component.
return f"v2:{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."""
try:
with _conn() as conn:
if conn is None:
return (False, None)
row = conn.execute(_SQL_GET_REVGEO, (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:
try:
with _conn() as conn:
if conn is None:
return
conn.execute(_SQL_PUT_REVGEO, (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."""
out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, "bytes": 0}
try:
with _conn() as conn:
if conn is None:
return out
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