All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / validate-observability (pull_request) Successful in 18s
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.
293 lines
15 KiB
Markdown
293 lines
15 KiB
Markdown
# 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`](../../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:
|
||
|
||
```python
|
||
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](06-contracts.md) 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
|
||
|
||
```bash
|
||
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](05-frontend.md).
|