thermograph/backend/alembic/versions/0002_climate_hypertables.py

113 lines
4.2 KiB
Python
Raw Permalink Normal View History

Move the climate record from parquet to TimescaleDB hypertables (#227) Replace the per-cell parquet cache with TimescaleDB hypertables as the production backend for the raw daily climate record, and drop pg_duckdb. Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a Postgres URL (dev, tests, offline tooling), the same dialect switch the accounts DB and derived store already use, so CI stays Postgres-free. - data/climate_store.py: psycopg + polars bridge over climate_history (a hypertable), climate_recent, and climate_sync (per-cell freshness). Reads via pl.read_database, writes via COPY + ON CONFLICT upsert; fail-soft to a cache miss so a DB hiccup degrades to upstream refetch. - data/climate.py: route every cache/mtime touchpoint through a backend dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the stale-serve path still avoids bumping it, so derived-payload tokens invalidate on exactly the same events as before. - alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema (compression policy on year-old chunks), guarded to no-op off Postgres. - migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the parquet cache into the hypertables, preserving file mtimes as sync timestamps so recent_stamp is unchanged across cutover. - db image -> stock timescale/timescaledb:latest-pg18; drop the custom pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb tuning GUC. Docs updated for the new backend and cutover. Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
"""climate record: TimescaleDB hypertables
Moves the raw daily climate record out of per-cell parquet files and into
Postgres, so the app queries the database instead of the filesystem (see
data/climate_store.py). Three objects:
* ``climate_history`` a TimescaleDB hypertable of the full daily archive,
durable/LOGGED (a 45-year, rate-limited refetch is expensive). Range-partitioned
on ``date`` with a large 5-year chunk interval (the access pattern is
all-rows-for-one-cell, so time chunking is incidental a big interval keeps the
chunk count tiny). Old chunks compress; the recent chunk stays writable for the
hourly tail top-up.
* ``climate_recent`` a plain table (NOT a hypertable) for the recent+forecast
bundle. It holds *future* dates and is fully rewritten hourly, both of which
fight time-partitioning.
* ``climate_sync`` per-cell freshness (epoch seconds), replacing the parquet
file mtimes that drove the topup cadence, the forecast TTL, and the
``recent_stamp`` token.
Postgres-only: guarded to no-op on the SQLite bind (tests / CI / offline Alembic),
where climate.py uses the parquet backend and these tables are never needed.
Revision ID: 0002_climate_hypertables
Revises: 0001_initial_schema
Create Date: 2026-07-20
"""
from alembic import op
revision = "0002_climate_hypertables"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
# The daily measurement columns shared by both tables (see data/climate_store.COLS).
_MEASURES = """
tmax DOUBLE PRECISION,
tmin DOUBLE PRECISION,
precip DOUBLE PRECISION,
wind DOUBLE PRECISION,
gust DOUBLE PRECISION,
humid DOUBLE PRECISION,
fmax DOUBLE PRECISION,
fmin DOUBLE PRECISION,
feels DOUBLE PRECISION
"""
def upgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return # SQLite fallback (tests/CI): climate uses the parquet backend.
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
# History: durable hypertable. PK (cell_id, date) includes the range-partition
# column (date), which TimescaleDB requires of any unique index, and gives the
# single-cell chronological scan the read path needs for free.
op.execute(f"""
CREATE TABLE climate_history (
cell_id TEXT NOT NULL,
date DATE NOT NULL,
{_MEASURES},
PRIMARY KEY (cell_id, date)
)
""")
op.execute(
"SELECT create_hypertable('climate_history', "
"by_range('date', INTERVAL '5 years'))")
# Columnar compression, grouped per cell. Only chunks older than a year are
# compressed — the hourly tail top-up only ever writes dates within the last
# week, i.e. the current uncompressed chunk, so compression never collides with
# a write.
op.execute("""
ALTER TABLE climate_history SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'cell_id',
timescaledb.compress_orderby = 'date'
)
""")
op.execute("SELECT add_compression_policy('climate_history', INTERVAL '365 days')")
# Recent + forecast bundle: plain table (holds future dates, rewritten hourly).
op.execute(f"""
CREATE TABLE climate_recent (
cell_id TEXT NOT NULL,
date DATE NOT NULL,
{_MEASURES},
PRIMARY KEY (cell_id, date)
)
""")
# Per-cell freshness — epoch seconds (DOUBLE mirrors store.py's updated_at and
# makes recent_stamp = int(recent_synced_at) trivial).
op.execute("""
CREATE TABLE climate_sync (
cell_id TEXT PRIMARY KEY,
history_synced_at DOUBLE PRECISION,
recent_synced_at DOUBLE PRECISION,
cols_version SMALLINT NOT NULL DEFAULT 1
)
""")
def downgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute("DROP TABLE IF EXISTS climate_sync")
op.execute("DROP TABLE IF EXISTS climate_recent")
op.execute("DROP TABLE IF EXISTS climate_history")
# Leave the timescaledb extension installed — other objects may rely on it and
# dropping an extension is a heavier, rarely-wanted operation.