thermograph/infra/deploy/POSTGRES-MIGRATION.md
Emi Griffith ae1d9bb534 Subtree-merge thermograph-infra (origin/main) into infra/
git-subtree-dir: infra
git-subtree-mainline: d6df04eab2
git-subtree-split: 99b4b3f78d
2026-07-22 22:01:11 -07:00

6.9 KiB

Cutover: SQLite → PostgreSQL 18 (containerized stack)

The app now runs as a docker-compose stack (app + db) and standardizes on PostgreSQL. Locally you need nothing but Docker; make up builds and starts both. This doc is the one-time production cutover from the old bare-systemd/SQLite deploy to the compose stack, migrating the authoritative accounts data.

What moved

  • accounts (users/subscriptions/notifications/…): SQLite → Postgres durable tables. Managed by Alembic (backend/alembic/); create_all on a fresh DB.
  • derived cache (store.py) and metrics (metrics.py): now Postgres UNLOGGED tables (fast, non-durable — same throwaway semantics). No data to migrate — the cache rebuilds, metrics start empty.
  • climate record (climate.py): the raw daily archive + recent/forecast bundle moved from per-cell parquet files into TimescaleDB hypertables (climate_history / climate_recent / climate_sync, managed by Alembic). The app reads/writes them via data/climate_store.py. Backfilled from the existing parquet cache with migrate_cache_to_pg.py (see below).
  • The app selects Postgres when THERMOGRAPH_DATABASE_URL is a postgresql+asyncpg URL; unset ⇒ the old SQLite/parquet behavior (this is how the test suite stays Postgres-free — no Postgres needed in CI, and climate falls back to parquet).
  • db runs the stock timescale/timescaledb:latest-pg18 image (genuine PG18 + TimescaleDB). The earlier pg_duckdb / read_parquet('/parquet/…') capability is gone — the climate record is real tables now, queryable directly (see deploy/db/README.md).

Connection model

Per worker (4 workers): a read-write asyncpg engine + a read-only asyncpg engine (default_transaction_read_only=on, used by the pure-GET endpoints), plus one sync psycopg engine for the notifier thread. Single Postgres primary — the RO engine is a guardrail, not a replica.

Prod cutover steps (maintenance window)

Prereq: the deploy user can run docker. Secrets in /etc/thermograph.env must include POSTGRES_PASSWORD, and pinned THERMOGRAPH_AUTH_SECRET, THERMOGRAPH_VAPID_PRIVATE_KEY/_PUBLIC_KEY, THERMOGRAPH_COOKIE_SECURE=1.

  1. Ship the stack, don't cut over yet. Deploy the branch; docker compose build. Bring up only the DB: docker compose up -d db. Add a nightly pg_dump backup.
  2. Freeze + back up. Stop the old app (hard stop — no writes). Back up the live data/accounts.sqlite + -wal/-shm (this is the rollback artifact).
  3. Build the schema. docker compose run --rm backend alembic upgrade head (or let the app entrypoint do it on first start — but do the data copy before real traffic).
  4. Copy the accounts data:
    docker compose run --rm \
      -e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:$POSTGRES_PASSWORD@db:5432/thermograph" \
      -v /opt/thermograph/data/accounts.sqlite:/src.sqlite:ro \
      backend python migrate_accounts_to_pg.py --sqlite /src.sqlite
    
    It copies user → subscription/push_subscription/pending_digest → notification, preserving PKs, skips access_token (everyone re-logins once), and resets the integer-PK sequences (skipping that = a runtime PK collision). Verify the printed per-table counts against the old DB.
  5. Start serving: docker compose up -d (Caddy already proxies 127.0.0.1:8137).
  6. Smoke test: login, list/create/delete a subscription, one notifier tick, a cached calendar/day request (derived store on PG), the metrics dashboard.
  7. Keep the frozen accounts.sqlite as rollback for a few days; keep PG backed up. Enable pg_duckdb on the (already-initialized) volume once, by hand: docker compose exec db psql -U thermograph -d thermograph -c 'CREATE EXTENSION IF NOT EXISTS pg_duckdb;'

Rollback

Redeploy the previous SQLite release (or point THERMOGRAPH_DATABASE_URL back to unset/SQLite and restart). Clean only during/immediately after the window — once real users write to Postgres, those writes are lost on rollback (the copy is one-way), so keep the window short and writes frozen during the copy.

TimescaleDB cutover (from the earlier pg_duckdb PG18 stack)

Swapping the db image from pgduckdb/pgduckdb:18 to timescale/timescaledb:latest-pg18 means a fresh PGDATA volume — and that volume also holds the durable accounts data. So this cutover is not a plain docker compose pull; it re-seeds both accounts and the climate record.

  1. Back up first. pg_dump the accounts tables from the old db container (docker compose exec db pg_dump -U thermograph -t user -t subscription \ -t notification -t push_subscription -t pending_digest thermograph > accounts.sql), or keep the pre-Postgres accounts.sqlite as the source of truth.
  2. Replace the DB. Deploy this branch (compose now points at the stock TimescaleDB image, no build:). Bring down the stack and remove the old volume so PGDATA re-inits on the new image: docker compose down && docker volume rm <project>_pgdata.
  3. Build the schema. docker compose up -d db, then docker compose run --rm backend alembic upgrade head — this creates the accounts tables and the climate hypertables, and CREATE EXTENSION timescaledb.
  4. Restore accounts. Either psql < accounts.sql, or re-run migrate_accounts_to_pg.py --sqlite <accounts.sqlite> (§ the SQLite cutover above). Verify per-table counts.
  5. Backfill the climate record from the parquet cache (mount it and run the backfill; idempotent, resumable):
    docker compose run --rm \
      -v /opt/thermograph/data/cache:/app/data/cache:ro \
      backend python migrate_cache_to_pg.py
    
    It loads climate_history + climate_recent and sets each cell's climate_sync to the parquet file mtime, so recent_stamp (hence every derived-payload token) is unchanged across the cutover. Cells without a schema-complete parquet record are skipped and refetch lazily on first request.
  6. Start serving: docker compose up -d. Smoke test: login, a subscription, a cached calendar/day request (served from climate_history), the metrics dashboard.
  7. Keep the pre-cutover DB backup (accounts dump + parquet cache) for a few days. The climate record is now durable — make sure pg_dump/PITR covers it.

Notes

  • The two deploy/migrations/*.sql files are superseded by Alembic on Postgres (they were SQLite-specific ALTER TABLE column-adds for the old prod DB).
  • The TimescaleDB image's data dir is /var/lib/postgresql/data; the compose volume is mounted at the parent /var/lib/postgresql so it persists without the initdb permission issue that pinning PGDATA to the mountpoint can trigger.
  • Climate falls back to the parquet cache whenever THERMOGRAPH_DATABASE_URL is not a Postgres URL (dev, tests, offline tooling) — the same dialect switch as the accounts DB, so a DB outage in prod degrades to upstream refetch, never a 500.