thermograph/backend
emi 2d3f37c474
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 8s
Sync infra to hosts / sync-prod (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 8s
shell-lint / shellcheck (push) Successful in 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m14s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
daemon: move the Discord gateway and scheduler out of the web process into Go (#21)
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.

It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.

The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.

replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.

THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.

Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.

365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
..
accounts Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
alembic Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
api daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
core Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
daemon daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
data Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
deploy Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
notifications daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
scripts shell: add shellcheck CI guard and drive the tree to zero findings (#19) 2026-07-23 22:26:05 +00:00
tests daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
web daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
.dockerignore daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
.gitignore Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
alembic.ini Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
app.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities_flavor.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
CLAUDE.md Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
docker-compose.test.yml Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
Dockerfile daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
drift_check.py Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
gen_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
gen_flavor.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
indexnow.py daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
Makefile Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
migrate.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_accounts_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_cache_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
paths.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
README.md backend: monorepo cutover marker (path-filter probe) 2026-07-23 03:18:03 -07:00
requirements-dev.txt Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
requirements-seed.txt Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
requirements.txt daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
seed_era5.py Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
warm_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00

thermograph-backend

The Thermograph API service: grades recent local weather against ~45 years of climate history, and hosts the accounts, notification, and SSR-content back-end that the rest of the split Thermograph stack (thermograph-frontend) talks to over HTTP. Split from the emi/thermograph monorepo — this repo owns the API/DB/accounts/notifications layer only; it renders no HTML/CSS/JS of its own.

See CLAUDE.md for the full split topology, deploy flow, and API version contract, and thermograph-docs (a sibling repo) for cross-cutting architecture decisions and operator runbooks.

How it works

  1. Grid — a lat/lon is snapped to a stable ~4 sq mi cell (data/grid.py); longitude spacing is scaled by cos(latitude) so cells stay roughly square at any latitude. The cell id is the cache key, so the same spot always resolves to the same data.
  2. Data (on-demand, cached to parquet) — the first request for a cell fetches the full 1980present daily record (max/min temp, precip) from the free Open-Meteo archive (ERA5) and writes it to data/cache/<cell_id>.parquet (zstd, ~200 KB for 45 years). Later requests read the parquet directly. Recent days come from Open-Meteo's forecast API (past_days), so history and grading share one source.
  3. Percentiles & grading (data/grading.py) — each day is graded against every historical day within ±7 days of it (a 15-day seasonal window, wrapping year-end), as an empirical mid-rank percentile. Temperature uses a symmetric tier ladder (TEMP_BANDS: Near Record / High / Above Normal / Normal / Below Normal / Low / Near Record); precipitation is graded separately (RAIN_BANDS) since most days are dry — a rainy day is ranked only among rain days in its window, dry days are colored by dry-streak length instead.
  4. Caching & ETags — every derived payload (grade/calendar/day/SSR content) is cached in SQLite (data/store.py) under (kind, cell_id, key), validated by a token that only advances when the cell's history actually changes. That token doubles as a weak ETag, so an unchanged request costs a 304 with no payload rebuild (api/payloads.py, web/app.py).

Layout

accounts/       fastapi-users models/schemas/db + api_accounts routes
alembic/        Postgres schema migrations (alembic upgrade head on boot)
api/            versioned payload builders (payloads.py, content_payloads.py)
                + route wiring (content_routes.py, sitemap.py, homepage.py)
core/           metrics, audit/access logging, a singleton helper
data/           grid snapping, climate fetch/cache, grading/scoring,
                places/cities, the derived-payload store
notifications/  push (VAPID), email, Discord bot (interactions + linking),
                monthly digest, the in-process scheduler (city warming, IndexNow)
web/app.py      the FastAPI app (routes, CORS, ETag/versioning, middleware)
deploy/         container entrypoint.sh (alembic migrate, then serve)
app.py          shim re-exporting web.app:app — keeps the launch target
                `app:app` stable regardless of internal package layout
scripts/        one-off admin scripts (Discord slash-command registration)
tests/          pytest suite, hermetic (see tests/conftest.py)
cities.json,
cities_flavor.json   bundled reference data (generated by gen_cities.py /
                     gen_flavor.py), not runtime state

How it fits the split

  • thermograph-frontend calls this service's GET /api/v2/... endpoints (grade, geocode, calendar) and the SSR content endpoints under /content/...; it negotiates compatibility via GET /api/version.
  • thermograph-infra owns the deploy/Compose/Terraform layer — this repo only builds and publishes its own container image (git.thermograph.org/emi/thermograph-backend/app) and hands infra a tag to roll out (SERVICE=backend + BACKEND_IMAGE_TAG into infra's deploy/deploy.sh).
  • thermograph-docs holds the cross-repo architecture/runbook docs; this README only covers what's local to this service.

Build & run

docker build -t thermograph-backend .
docker run -p 8137:8137 --env-file .env thermograph-backend

The image runs deploy/entrypoint.sh: alembic upgrade head against THERMOGRAPH_DATABASE_URL (retried, since a fresh Postgres volume can still be starting up), then uvicorn app:app on $PORT (default 8137) with $WORKERS workers (default 4). /healthz is an I/O-free liveness probe.

For local development without a container, see the "Run / test locally" section of CLAUDE.md — there is no Makefile in this repo yet, so it's a plain venv + pytest/uvicorn invocation.

Notifications: Discord slash commands

notifications/discord_interactions.py answers Discord's HTTP Interactions endpoint (/discord/interactions) for the /grade <city> slash command. Registering (or updating) the command definition with Discord's REST API is a one-off admin action, not part of the running app:

THERMOGRAPH_DISCORD_APP_ID=... THERMOGRAPH_DISCORD_BOT_TOKEN=... \
    python3 scripts/register_discord_commands.py

Global command changes can take up to an hour to propagate.