"""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