thermograph/docs/onboarding/04-backend.md
Emi Griffith 4e97d8e5dc
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Successful in 18s
PR build (required check) / gate (pull_request) Successful in 2s
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1
Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:

  vps1  75.119.132.91  Forgejo, Grafana/Loki, the portfolio site, and DEV
                       (own Postgres, mesh-only on 10.10.0.2:8137)
  vps2  169.58.46.181  PROD and BETA as two Swarm stacks sharing one
                       TimescaleDB instance, plus Centralis, Postfix, backups
  desktop              AI model hosting + flex Swarm capacity, no environment

Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.

deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.

Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.

One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.

Fixes that co-residency would otherwise have broken silently:

- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
  also "the Forgejo box" because those shared a machine; that conflation is what
  once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
  prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
  have filed every beta line as prod, feeding prod's alert rules with beta's
  traffic. It is now derived per source, with a new `node` label for the machine,
  and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
  target and env-file path from the topology instead of hardcoding beta to
  75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
  vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
  unreviewed branches on a VPS.

Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.

Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
2026-07-25 15:01:29 -07:00

15 KiB
Raw Blame History

4. Backend deep dive

FastAPI on Python 3.12, plus a Go daemon that ships in the same image. Owns every piece of climate data and every bit of grading logic in the project.

Read backend/CLAUDE.md alongside this — it is the binding, terse version.

The one-process-many-roles model

The backend image runs five different jobs depending on THERMOGRAPH_ROLE and which binary compose/Swarm selects. One image means version skew between them is impossible.

Service How it's selected Does
web THERMOGRAPH_ROLE=web Serves requests. Never starts the notifier, even if it would win the election — so the web tier scales to N replicas without also scaling background sweeps.
worker THERMOGRAPH_ROLE=worker Owns the subscription notifier. Still serves requests today.
all default Both — the single-process dev default.
lake THERMOGRAPH_ROLE=lake deploy/entrypoint.sh execs uvicorn lake_app:app on port 8141 instead. No database, no migrations. Runs in every environment — its own Swarm service on prod and beta (lake / beta-lake), a compose service on dev.
daemon compose/Swarm sets the command to /usr/local/bin/thermograph-daemon The Go binary: Discord gateway + cron timers.

deploy/entrypoint.sh runs alembic upgrade head (retried — a fresh Postgres volume can still be starting) and then serves. /healthz and /api/version are deliberately I/O-free so they stay cheap under tight healthcheck intervals; they're also deliberately not under THERMOGRAPH_BASE, so a healthcheck needn't know the base path.

The data pipeline

Where history comes from — the source ladder

data/climate.py::_load_history walks this ladder on a cache miss, and the order matters:

  1. The ERA5 lake (data/era5lake.py) — our own parquet extract of the public ERA5 archive in Contabo object storage. True ERA5 with measured gusts, no third-party API in the path. Unconfigured or missing-point cells fall through at zero cost.
  2. The Open-Meteo archive — the same ERA5 family, so a backup-sourced record grades consistently with lake-sourced neighbours. Guarded by its own rate-limit cooldown.
  3. Stale cache, served without rewriting it (so recent_stamp stays old and the next request retries upstream first).
  4. Otherwise WeatherUnavailable → a clean 503.

NASA POWER is retired from every serving path. Its MERRA-2 record ran ~1.3 °F off ERA5 and carried no gusts. drift_check.py still fetches it, and only that.

Every source must return a plausibly-full span (MIN_ARCHIVE_DAYS) before it's accepted and cached as complete — a short response is rejected, not stored.

Where "today" comes from

_load_recent_forecast is a separate ladder:

  1. Open-Meteo forecast API — recent past and forward days in one call, with gusts, ERA5-consistent with the history record. Skipped while its own cooldown is active.
  2. Fallback — an archive recent-past range plus MET Norway forward days. Costs no Open-Meteo forecast quota.
  3. Stale cache, again without rewriting.

Cached per cell for FORECAST_TTL_HOURS, so it tracks model updates without hourly upstream I/O. RECENT_PAST_DAYS = 25, FORECAST_DAYS = 8.

Concurrency and quota guards

  • A per-cell lock serialises archive fetches, so N concurrent first-requests for one cell cost one fetch.
  • Rate limits set module-level cooldowns (_archive_cooldown_until, _forecast_cooldown_until) rather than retrying into the wall.
  • Reverse geocoding runs on one dedicated worker thread draining a queue at ~1 req/s. The earlier design slept inside the caller's thread — which, in the server, is one of Starlette's shared sync-threadpool threads.

Storage: three tiers with three different contracts

Tier Module Backend Contract
Raw climate record data/climate_store.py TimescaleDB climate_history hypertable (durable), climate_recent (rewritten each refresh), climate_sync (freshness) Source of truth. Expensive to refetch. Fail-soft: a miss degrades to fetch-from-upstream. Parquet files instead when THERMOGRAPH_DATABASE_URL isn't Postgres (dev, tests, offline tooling).
Derived payloads data/store.py SQLite WAL, or UNLOGGED Postgres tables Pure accelerator. Every reader falls back to recomputing from the raw record; every helper swallows its own errors. Deleting the store is a safe full reset.
Accounts accounts/db.py Postgres (or SQLite) via SQLAlchemy Not regenerable. Foreign keys enforced, errors surface rather than get swallowed, must be backed up.

That third row is the important distinction: accounts, subscriptions and notifications have no source to recompute from, so they live under stricter rules than everything else in the process.

On Postgres, accounts/db.py runs two async engines over one primary — read-only (default_transaction_read_only) for pure-GET endpoints, read-write for everything else — plus a separate sync psycopg engine for the notifier thread, which has no event loop.

The validity token

Derived rows are keyed (kind, cell_id, request_key) and guarded by a token that encodes everything the payload depends on:

PAYLOAD_VER = "p2"                       # api/payloads.py
history_token(history)  f"{PAYLOAD_VER}:{hist_end(history)}"
content_token(...)      f"{PAYLOAD_VER}:{CONTENT_VER}:{max_date}"

A stored token that doesn't match the caller's current token is a miss. There is no clock-based expiry anywhere. That's why one PAYLOAD_VER bump atomically orphans every pre-upgrade row — and why forgetting to bump it serves old-shaped JSON forever. CONTENT_VER is kept separate so a content-only shape change doesn't invalidate the whole grading cache.

The same token doubles as a weak ETag. See contracts for what that means for CORS.

Grading

data/grading.py is the heart of the product:

  • For a target day of year, the reference distribution is every historical day within ±HALF_WINDOW (7) days, wrapping the year boundary — a 15-day seasonal window.
  • The observed value is placed as an empirical mid-rank percentile, which handles ties correctly (crucial: most days have zero precipitation).
  • TEMP_BANDS is a symmetric seven-tier ladder; RAIN_BANDS is separate, and dry days are coloured by dry-streak length instead of rank.
  • pct_ordinal() floors a percentile into 1..99 — never 0, never 100. frontend/static/shared.js::pctOrd() mirrors it. If they diverge, the same reading says two different things on two surfaces.

data/scoring.py is a different question: how far a cell's recent years have drifted from its full multi-decade baseline, per metric and per percentile category, expressed in unit-free percentile points. Note baseline_overlaps_recent in the payload — the baseline deliberately includes the recent years, which mildly and uniformly attenuates the divergence.

The HTTP surface

web/app.py mounts routers so that every version stays served simultaneously:

/api        → v1 (legacy unversioned alias)
/api/v1     → v1        geocode, grade
/api/v2     → v2        geocode, suggest, place, grade, calendar, day,
                        score, forecast, cell, metrics, event
/api/v2     → content_routes:  /content/hub, /content/sitemap,
                        /content/indexnow-key, /content/home,
                        /content/city/{slug}[/month/{month}|/records]
/api/v2/auth, /users    fastapi-users: login, logout, register, verify, me
/api/v2/subscriptions, /notifications, /push/*   accounts/api_accounts.py
/api/v2/discord/*       account linking (OAuth2)
/internal/*             the Go daemon's control surface — NOT under BASE
/healthz, /api/version  I/O-free
/discord/interactions   Discord slash-command webhook (Ed25519-verified)
/digest                 the footer signup form
/{path:path}            catch-all proxy to the frontend  ← registered LAST

Two routes worth understanding properly:

GET /api/v2/cell — one bundle carrying every view's payload for a cell, so the client warms four views with one request. Each slice is the exact payload its per-view endpoint returns, built by the same builders under the same derived-store keys and tokens, paired with the ETag that endpoint would emit. The client seeds its per-view cache from the slices and later revalidates each view individually. prefetch=1 is the warm-only mode with a hard guarantee: it never spends weather-API quota — a cold cell answers 204 with no body. neighbors=1 additionally enqueues the 8 surrounding cells for background warming, also warm-only.

The catch-all proxy — the frontend owns every page and asset. In prod and beta, Caddy path-splits directly, so this proxy is never exercised. It exists as the fallback for environments with no Caddy in front (dev, bare-metal). It forwards X-Forwarded-Host/-Proto so the frontend can build correct absolute URLs instead of resolving to the internal hop's own address. Because it's registered dead last, every real backend route wins first — /internal/* in particular is registered before it, deliberately.

Background work: who runs what, and where

This is where the project's hardest-won lessons live.

  • Neighbour warmer — a per-worker daemon thread draining a request-fed queue. Correct to run in every worker: each drains its own queue.
  • Subscription notifier (notifications/notify.py) — a timer-driven sweep that issues upstream fetches independent of any request. Running it in N workers multiplies Open-Meteo quota use N-fold. That is not hypothetical: it is the documented cause of overnight 429/503s after prod went to 3 workers. So it runs behind singleton.claim_leader():
    • claim() — a non-blocking flock on a lockfile, arbitrating workers on one machine;
    • claim_pg() — a Postgres advisory lock, visible to every host talking to the same database, arbitrating cluster-wide under Swarm;
    • unset (dev, tests, single worker) ⇒ always the leader.
  • Heartbeat — deliberately unguarded across every worker and replica. A beat is one local file append (no quota, no DB, no lock), so duplicates are free, and only the process actually being dead stops them. Container-log silence can't distinguish a live web/worker process from a dead one, so this is the signal observability alerts on.
  • Discord gateway + recurring jobs — moved out of this process entirely into the Go daemon. See below.

The notifier's own quiet guards: a UNIQUE(subscription_id, event_date, metric, direction, kind) row-level dedup, and a weekly cap — a subscription that notified within 7 days is skipped entirely. Archive reads come from cache; a freshly-subscribed cell is fetched once, budget-capped per pass.

The Go daemon (backend/daemon/)

One binary, built into the backend image, running as exactly one replica.

Why it exists: the Discord gateway tolerates one session per token, and duplicated timers repeat every job. One replica makes that a deployment fact instead of a runtime election.

Why it's thin: Go owns no climate or grading logic. Grading needs polars and the cache, and the slash-command path deliberately shares one grade builder with the API so bot grades and API grades can never drift. So anything data-shaped is a POST back into Python:

Route Purpose
POST /internal/discord/grade {"query": "phoenix"} → gateway-ready Discord message JSON, relayed verbatim
POST /internal/jobs/warm-cities trigger the warm-cities job
POST /internal/jobs/indexnow trigger IndexNow check-and-submit

Every request carries X-Thermograph-Internal-Token. Both ends fail closed: with no THERMOGRAPH_INTERNAL_TOKEN, Python answers 404 across the whole surface and the daemon refuses to start. Caddy never routes /internal/* publicly, so the token is defence in depth, not the only control.

The cron half runs unconditionally (warm-cities every 24h, indexnow every 6h, both configurable). A fatal gateway error logs loudly and leaves cron running — a Discord misconfiguration must not become a crash loop that also stops the schedule. The cron scheduler defers the first run by one interval, never overlaps a job with itself, and drops ticks that fire mid-run.

The lake (lake_app.py + data/era5lake.py)

Runs as a Swarm service on both prod and beta (lake / beta-lake, same image, THERMOGRAPH_ROLE=lake), and as a compose service on dev.

The lake is parquet in an S3-compatible bucket (Contabo, era5-thermograph), extracted once from the public Earthmover ERA5 Icechunk archive by gen_era5_lake.py. Two layouts under era5/:

  • daily/tile=<ti>_<tj>/year=<Y>/month=<M>/part.parquet — the analytical hive table. The location grain is a 12×12-point tile (~3°×3°) aligned to the source's chunk grid; a per-point-per-month grain would be ~170M objects.
  • points/lat=<i>/lon=<j>.parquet — the serving projection: one file per grid point with its full history, so the hot path is a single GET.
  • manifest.parquet — an index of every point file; readers prune with it and the extractor resumes from it.

Endpoints: /healthz, GET /history?lat=&lon= (the hot path — nearest point's history as parquet bytes, disk-cached per point), POST /query (SELECT-only DuckDB SQL over era5_daily and manifest).

Two operational facts: Contabo is path-style only with region=default (virtual-host addressing fails outright), and DuckDB's httpfs/iceberg extensions are baked into the image as uid 10001, because extensions install to the invoking user's ~/.duckdb and a root-time bake left the runtime user unable to LOAD them — seen live as prod's lake /query returning 500.

Filter on partition columns (tile/year/month). That predicate is the difference between a pruned scan and reading the whole warehouse.

Observability hooks inside the app

  • core/audit.py writes JSONL streams: audit (one line per graded run, with per-phase timings and a full/partial run type), errors (upstream failures tagged retry or error), plus access, activity and heartbeat. Alloy parses these so level/tag/phase become Loki labels.
  • core/metrics.py keeps since-start counters, shared across uvicorn workers via THERMOGRAPH_METRICS_DB (a SQLite file) — unset keeps the zero-dependency in-memory store. Read over the token-gated GET /api/v2/metrics.
  • Every unhandled 500 gets an explicit exception handler emitting tag="http.error". Without it the exception propagates past the middleware and the 500 is invisible in both the access log and metrics.

Working on it

cd backend
make test                  # hermetic pytest, ~12s
make test ARGS='tests/data/test_grading.py -q'
make smoke                 # build the image, boot it + a throwaway db

CI runs the suite inside the built image, so the exact interpreter and dependencies that ship are what gets tested.

Next: Frontend deep dive.