thermograph/docs/onboarding/04-backend.md
emi d138f00a20
Some checks failed
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Failing after 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 13s
Validate observability stack / validate (push) Successful in 17s
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 6s
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 — beta joins prod, dev gets a home (#103)
2026-07-26 06:56:38 +00:00

293 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).