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>
109 lines
5.1 KiB
YAML
109 lines
5.1 KiB
YAML
# Thermograph production stack: the FastAPI app plus its TimescaleDB (PostgreSQL 18)
|
|
# database.
|
|
#
|
|
# docker compose up -d --build # or: make up
|
|
#
|
|
# POSTGRES_PASSWORD must be set at `docker compose` time — compose reads it from
|
|
# the repo-root .env for local runs (copy .env.example -> .env), and in prod the
|
|
# systemd unit's EnvironmentFile=/etc/thermograph.env puts it in the environment
|
|
# so `docker compose up` can interpolate it. It is used BOTH to initialize the db
|
|
# container and to build the app's THERMOGRAPH_DATABASE_URL below.
|
|
|
|
services:
|
|
db:
|
|
# TimescaleDB on PostgreSQL 18 (the stock image already sets
|
|
# shared_preload_libraries=timescaledb). The app's climate record — the full
|
|
# daily archive and the hourly recent+forecast bundle — lives in hypertables
|
|
# here (see backend/data/climate_store.py), so the DB, not the filesystem, is the
|
|
# source of truth. The init script CREATE EXTENSIONs timescaledb on a fresh volume
|
|
# (Alembic also does, idempotently, at app boot).
|
|
image: timescale/timescaledb:latest-pg18
|
|
environment:
|
|
POSTGRES_USER: thermograph
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
POSTGRES_DB: thermograph
|
|
# The init tuning script (deploy/db/init/20-tuning.sh) scales the Postgres +
|
|
# DuckDB memory GUCs from this budget, matching the mem_limit below. One knob
|
|
# per host: Terraform sets DB_MEMORY (prod 16g), local/beta default 8g.
|
|
DB_MEMORY: ${DB_MEMORY:-8g}
|
|
volumes:
|
|
# Mount the volume at the PARENT of the data dir and let the image pick its own
|
|
# PGDATA subdir under it (the timescaledb image defaults to
|
|
# /var/lib/postgresql/data). Pinning PGDATA directly at the mountpoint trips an
|
|
# initdb chmod on some Docker setups; the whole tree still persists this way.
|
|
- pgdata:/var/lib/postgresql
|
|
- ./deploy/db/init:/docker-entrypoint-initdb.d
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
# Give Postgres room to cache + process. mem_limit is the hard ceiling; the actual
|
|
# budget is tuned in deploy/db/init/20-tuning.sh, which scales shared_buffers (25%),
|
|
# effective_cache_size (75%), work_mem and maintenance_work_mem from DB_MEMORY — so
|
|
# raising DB_MEMORY raises both the cap and the tuning together. shm_size backs
|
|
# parallel-query shared memory (the 64MB docker default is
|
|
# too small once shared_buffers/parallelism grow).
|
|
# Sized via env (Terraform sets DB_CPUS/DB_MEMORY per host); defaults match the
|
|
# historical 2 CPU / 8 GB budget so a plain `docker compose up` is unchanged.
|
|
mem_limit: ${DB_MEMORY:-8g}
|
|
shm_size: 1gb
|
|
# Cap the DB at DB_CPUS CPUs. Compose v2 honors the top-level `cpus:`; the
|
|
# deploy.resources block is the Swarm-style equivalent, kept for parity.
|
|
cpus: ${DB_CPUS:-2}
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: "${DB_CPUS:-2}"
|
|
# Must match mem_limit above (compose rejects distinct values).
|
|
memory: ${DB_MEMORY:-8g}
|
|
restart: unless-stopped
|
|
# No host port on purpose: the app reaches Postgres as db:5432 on the
|
|
# compose network. Nothing outside the stack should touch the database.
|
|
|
|
app:
|
|
build: .
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
# Built from POSTGRES_PASSWORD; this `environment` value wins over anything
|
|
# in env_file, so the URL always matches the db container's password.
|
|
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
|
|
THERMOGRAPH_BASE: /
|
|
PORT: 8137
|
|
# Worker count is env-driven so Terraform can raise it on a bigger host;
|
|
# defaults to 4 to keep a plain `docker compose up` identical to before.
|
|
WORKERS: ${WORKERS:-4}
|
|
# One worker wins this lock and runs the subscription notifier / homepage
|
|
# sweep; it lives on the appdata volume so it's shared across workers.
|
|
THERMOGRAPH_SINGLETON_LOCK: /app/data/notifier.lock
|
|
# Prod secrets live in /etc/thermograph.env: POSTGRES_PASSWORD,
|
|
# THERMOGRAPH_AUTH_SECRET, THERMOGRAPH_VAPID_PRIVATE_KEY/_PUBLIC_KEY,
|
|
# THERMOGRAPH_COOKIE_SECURE=1, mail/Discord keys, ... (see
|
|
# deploy/thermograph.env.example). `required: false` so local `docker compose
|
|
# up` works without that file — it reads POSTGRES_PASSWORD from repo-root .env.
|
|
env_file:
|
|
- path: /etc/thermograph.env
|
|
required: false
|
|
volumes:
|
|
# Parquet cache, notifier.lock, homepage.json, vapid.json persist here.
|
|
- appdata:/app/data
|
|
- applogs:/app/logs
|
|
# Sized via env (Terraform sets APP_CPUS per host); defaults to 4 CPUs. The
|
|
# deploy.resources block mirrors the top-level `cpus:` for Swarm parity; the
|
|
# dev overlay drops both so dev runs uncapped.
|
|
cpus: ${APP_CPUS:-4}
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: "${APP_CPUS:-4}"
|
|
ports:
|
|
# Loopback only — host Caddy terminates TLS and reverse-proxies to this.
|
|
- "127.0.0.1:8137:8137"
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
pgdata: {}
|
|
appdata: {}
|
|
applogs: {}
|