thermograph/backend/migrate_accounts_to_pg.py

79 lines
3.3 KiB
Python
Raw Normal View History

Containerize the app and move the databases to PostgreSQL 18 (#220) Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the data layer on Postgres, while keeping the test suite on SQLite. - accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write + read-only asyncpg pair (the RO engine pins read-only transactions, used by the pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL is unset). models.py: boolean server_default -> sa.false(). - store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg when configured, else the existing raw-sqlite3 paths byte-for-byte; sync interfaces and every fail-soft contract preserved. - Alembic (backend/alembic/) manages the accounts schema; the container entrypoint runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced), skips access_token, and resets identity sequences. - Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137) and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet'). - deploy.sh/thermograph.service rewired to manage the compose stack; env example, Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook. Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was verified via docker compose: alembic migrations, register/login, the RO endpoint, store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
"""One-shot copy of the accounts database from SQLite into PostgreSQL.
Run ONCE during the cutover, after ``alembic upgrade head`` has built the empty
Postgres schema. The copy goes through the mapped ORM tables, so SQLAlchemy's type
processors coerce the values automatically CHAR(36) UUID strings native
``uuid``, 0/1 ``bool``, epoch floats stay floats, JSON stays JSON which a raw
``pg_dump``/CSV path would need manual casts for.
Login tokens (``access_token``) are deliberately NOT copied: they're ephemeral, so
skipping them just makes everyone re-login once at cutover and avoids the tz/uuid
fiddliness. Integer-PK sequences are reset afterwards, or the first post-migration
insert would collide.
Usage:
THERMOGRAPH_DATABASE_URL=postgresql+psycopg://thermograph:PW@127.0.0.1:5432/thermograph \\
python migrate_accounts_to_pg.py --sqlite /opt/thermograph/data/accounts.sqlite
"""
import argparse
import os
import sys
from sqlalchemy import create_engine, insert, select, text
from accounts import models
# Parents before children (FK order); AccessToken is intentionally omitted.
TABLES = [
models.User.__table__,
models.Subscription.__table__,
models.PushSubscription.__table__,
models.PendingDigest.__table__,
models.Notification.__table__, # references user + subscription
]
# Integer surrogate-PK tables whose sequence must be advanced past the copied max.
SEQUENCE_TABLES = ["subscription", "push_subscription", "pending_digest", "notification"]
def _pg_sync_url() -> str:
url = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
if not url.startswith("postgresql"):
sys.exit("THERMOGRAPH_DATABASE_URL must be a postgresql URL (the migration target)")
return url.replace("+asyncpg", "+psycopg")
def migrate(sqlite_path: str) -> None:
if not os.path.exists(sqlite_path):
sys.exit(f"source SQLite file not found: {sqlite_path}")
src = create_engine(f"sqlite:///{sqlite_path}", future=True)
dst = create_engine(_pg_sync_url(), future=True)
counts = {}
with src.connect() as s, dst.begin() as d:
for table in TABLES:
rows = [dict(r._mapping) for r in s.execute(select(table))]
if rows:
d.execute(insert(table), rows) # explicit PKs preserved (FKs stay intact)
counts[table.name] = len(rows)
# Advance each identity sequence past the copied rows.
for name in SEQUENCE_TABLES:
d.execute(text(
f"SELECT setval(pg_get_serial_sequence('{name}', 'id'), "
f"COALESCE((SELECT MAX(id) FROM {name}), 1), (SELECT COUNT(*) > 0 FROM {name}))"
))
src.dispose()
dst.dispose()
print("Copied (access_token skipped — users re-login once):")
for name, n in counts.items():
print(f" {name:18} {n}")
print("Sequences reset for:", ", ".join(SEQUENCE_TABLES))
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--sqlite", default=os.environ.get("THERMOGRAPH_ACCOUNTS_DB")
or os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite"),
help="path to the source accounts.sqlite (default: data/accounts.sqlite)")
migrate(os.path.abspath(ap.parse_args().sqlite))