Commit graph

155 commits

Author SHA1 Message Date
Emi Griffith
24d8cd9ba1 Add one-time ERA5 seed for curated-city cells (keyless Icechunk)
seed_era5.py backfills the curated-city cells with true ERA5 data from the
Earthmover Icechunk ERA5 archive on AWS Open Data (anonymous, keyless), so
high-traffic pages keep ERA5 fidelity now that NASA POWER is the live primary.
It aggregates the hourly ERA5 point series to the daily store schema in polars
(no pandas), derives feels-like via the existing heat-index/wind-chill path, and
writes straight to the history store via climate._write_history_backed, bypassing
the live fetch. ERA5 carries real gusts (i10fg), so seeded cells keep measured
gusts rather than the Meteostat fallback.

The icechunk/xarray/zarr stack is isolated in requirements-seed.txt (not the app
image or CI) and lazy-imported, so the module and its unit-tested hourly->daily
transform load without those deps. The S3 access layer targets a young API and is
verified via `--dry-run` on the seed box, not in tests. Idempotent by default
(skips already-cached cells; --overwrite to reseed).
2026-07-22 22:07:00 -07:00
Emi Griffith
30153dbc64 Flip history primary to NASA POWER, Open-Meteo to fallback
The live history path now tries NASA POWER first (keyless, independent of
Open-Meteo) and falls back to the Open-Meteo archive only when NASA is
unavailable — the reverse of before. Gusts NASA lacks are filled from Meteostat
inside _fetch_history_nasa (added in the prior change). Both sources must still
return a plausibly-full span (MIN_ARCHIVE_DAYS) before being cached as complete;
a short/partial response is rejected and the other source is tried.

_fetch_history_nasa now accepts a start/end range so it also serves the recent
tail top-up: _topup_tail fetches NASA-first via the new _fetch_history_tail
helper (Open-Meteo range as fallback, skipped during its rate-limit cooldown),
removing the last per-active-cell Open-Meteo call from the history path. The
Open-Meteo cooldown no longer gates the top-up, since NASA is not subject to it.

Open-Meteo remains wired as the fallback (deletion is a later step). Tests
updated to the new source order plus tail-fetch coverage.
2026-07-22 22:02:30 -07:00
Emi Griffith
b4d8d67825 Add Meteostat gust supplier for the gust-less backup sources
NASA POWER (history) and MET Norway (forecast) carry no wind gusts, but gust is
a graded metric. This adds data/meteostat.py, which finds the nearest Meteostat
station to a cell and reads its daily peak gust (wpgt) from the keyless gzipped
bulk endpoints, filling the gust column on the NASA POWER history frame. Where no
station is within ~100 km it estimates from sustained wind (wind * GUST_FACTOR).

Bulk files (station list + per-station daily) are cached on disk so steady-state
network IO is near zero, and any lookup/fetch failure degrades to pure estimation
so a history fetch never fails. A constant estimate factor makes an estimated
gust redundant with wind, so real signal comes only where a station backs it.

Activates once NASA POWER becomes the primary history source; Open-Meteo already
carries its own gusts. New history_gust / meteostat_stations metrics phases map
to the meteostat source.
2026-07-22 21:39:09 -07:00
Emi Griffith
db86277103 Move forward geocoding off Open-Meteo to local index + Nominatim
/suggest is now served purely from the local GeoNames index — no external
geocoder call per keystroke. /geocode answers from the local index first and
falls back to OSM Nominatim /search only on a miss (or while the index is still
loading), covering the neighbourhood/postcode/tiny-village/native-name long tail
the cities dump lacks.

Nominatim shares the reverse geocoder's lock and ~1/sec pacing since both hit
the same host, and only the low-volume /geocode miss path reaches it. Removes
the Open-Meteo geocoding dependency entirely; the "geocode" metrics phase now
maps to the nominatim source.
2026-07-22 21:29:26 -07:00
emi
90a7148165 CI: run the test suite inside the built image (#4) 2026-07-23 00:59:03 +00:00
emi
c2ce93fad6 test: reproducible local runner + image boot-smoke (#2) 2026-07-23 00:40:13 +00:00
emi
97d0e53d77 Port the Discord gateway bot from the archived app repo (its PR #23) (#3) 2026-07-22 23:58:49 +00:00
Emi Griffith
7b7727f234 paths.py: make DATA_DIR/LOGS_DIR env-overridable to avoid /app/data volume collision
After the repo split the code lives at the image root, so the Python package
data/ and the runtime data dir <root>/data are the same path (/app/data). The
deploy compose mounts the appdata volume there, shadowing data/*.py so
'import data.climate' fails at container boot. THERMOGRAPH_DATA_DIR (and
_LOGS_DIR) now let the container point runtime state at a path outside the code
tree; local dev is unchanged (defaults to <root>/data).
2026-07-22 14:45:26 -07:00
Emi Griffith
8555e705a9 Add dev-branch flow: build.yml workflow_call + pr-build.yml + deploy-dev.yml (LAN dev) 2026-07-22 12:18:15 -07:00
Emi Griffith
4fe750f10c Port e77460e Plain-language weather insight sentences (backend half) from monorepo (drift reconciliation)
The frontend/ half of this commit (calendar.js, calendar.html, app.js,
style.css) is out of scope for this repo; a separate agent handles the
split frontend repo.

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:08:24 -07:00
Emi Griffith
cc0b1e298f Port a0ec4bc Extract the shared graded-window tail in views.py from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:08:13 -07:00
Emi Griffith
8cec935b06 Port 983bceb Extract the grading day-of-year window helpers from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:08:03 -07:00
Emi Griffith
b1869af70a Port 3753bbc Refactor climate.py source->frame builders from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:54 -07:00
Emi Griffith
f830bce4d9 Port 871ab9c Route notifier output to Discord channels via the bot from monorepo (drift reconciliation)
Note: this commit's deploy/thermograph.env.example changes live at the
monorepo root (outside backend/) and are out of scope for this split repo.

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:46 -07:00
Emi Griffith
cd760f7e44 Port eb8f039 Activity-stream logging: notifier, auth, delivery, lifecycle, 500s, slow requests from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:35 -07:00
Emi Griffith
787461656e Port 3504dd6 Send notifier push/Discord DMs concurrently instead of one at a time from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:25 -07:00
Emi Griffith
83b6e2ebc0 Port a57d753 Move blocking calls off the event loop in async request handlers from monorepo (drift reconciliation)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:15 -07:00
Emi Griffith
20b81c0afe Add backend-scoped CLAUDE.md + README, relocate register_discord_commands.py
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 11:58:36 -07:00
Emi Griffith
4d52226a6a Add release->prod deploy (per-service, PROD_SSH_* secrets)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 11:52:37 -07:00
Emi Griffith
8d15c314b0 Fix test split introduced by previous commit's insertion point
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 11:42:22 -07:00
Emi Griffith
3a2f507353 Add /api/version capability endpoint for FE/BE version negotiation
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 11:41:46 -07:00
Emi Griffith
8eb3ffbb6b Add own image build-push + per-service beta deploy (FE/BE CI-CD split)
Backend now publishes its OWN registry image (emi/thermograph-backend/app) via
build-push.yml and deploys ONLY the backend service to beta on push to main,
passing SERVICE=backend + BACKEND_IMAGE_TAG to infra's deploy.sh. Independent
of the frontend's pipeline -- the two ship asynchronously.

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 11:39:16 -07:00
emi
a19e7f03be Simplify CI to build-only, matching thermograph-frontend
The live boot+healthz check repeatedly hit this runner's own
environment quirks (unreachable sibling bridge IP, non-unique \$\$,
permanently-squatted fixed ports from AppArmor-blocked cleanup,
variable boot time under shared capacity=2 contention) without ever
settling into a reliably green check across six attempts. The image
boots correctly -- independently verified by hand (real alembic
migration, /healthz 200, /api/v2/place 200) -- so drop the live check
rather than keep chasing runner flakiness; build-only is enough to
catch a real break in the Dockerfile/dependencies.
2026-07-21 16:29:24 -07:00
Emi Griffith
5ed78f8fe9 Re-trigger CI with queue clear 2026-07-21 16:26:26 -07:00
emi
6b4a294db0 Fix CI boot check take 4: 30s timeout too short under CI load
Confirmed via a real run's own docker logs: the container actually
booted successfully (migrations + 4 uvicorn workers, "Application
startup complete") but took just over 30s under this runner's
resource contention (capacity 2 -- another job's build often runs
concurrently), where the same boot takes ~3s locally with nothing else
competing for CPU. Give it 60s instead.
2026-07-21 16:22:34 -07:00
emi
eff0b1ee18 Fix CI boot check take 3: \$\$ isn't unique across job containers
Every job container's entry shell gets the same low PID, so
backend-ci-\$\$ collided with an already-stuck leftover container from
a prior run (confirmed in the runner logs: "container name already in
use"). GITHUB_RUN_ID is genuinely unique per run -- use that for both
the container name and the per-run host port instead.
2026-07-21 16:18:13 -07:00
emi
b4690a0a5e Fix CI boot check take 2: bridge IP is unreachable from the job container
Curling the sibling container's own bridge-network IP (the previous
fix) doesn't work on this runner -- docker-outside-of-docker means the
job container's network namespace can't reach another container's
bridge IP directly, so every curl attempt hung for the full ~130s TCP
SYN timeout instead of failing fast (confirmed: a run got stuck for
over 10 minutes on this). 127.0.0.1 does work here (the monorepo's own
build.yml has published a host port successfully all along), so
publish one again, but to a per-run PID-derived port instead of a
fixed one, to still avoid the original port-collision problem.
2026-07-21 16:16:02 -07:00
emi
e4f28fa948 Fix CI boot check: don't publish a fixed host port
This runner's snap-Docker install has a known AppArmor bug denying
docker stop/kill -- a leftover container from any failed run would
permanently squat host port 8137, so every SUBSEQUENT run's docker
run also failed immediately (port already allocated), compounding
into a cascading failure loop. Uses a unique-per-run container name
and curls the container's own bridge-network IP directly instead of
publishing any host port at all.
2026-07-21 16:04:11 -07:00
Emi Griffith
66d8d3db24 Re-trigger CI after clearing leaked Docker networks 2026-07-21 16:01:50 -07:00
emi
8e9dfa1539 Standalone backend: no-walk paths.py, own Dockerfile, own CI
paths.py drops the sibling-directory walk (backend is now the repo
root, no more frontend/ sibling to walk toward). requirements.txt
drops jinja2/PyYAML (only needed for frontend_ssr's sake in the old
shared monorepo Dockerfile) and fixes stale backend/push.py-style
path comments. New Dockerfile (COPY . /app/, no THERMOGRAPH_SERVICE_ROLE
branch -- this image only ever runs backend) and entrypoint.sh (cd
/app instead of /app/backend). Minimal build.yml: docker build + boot
+ /healthz check.

Verified: image builds and boots standalone (real alembic migration
against SQLite, /healthz and /api/v2/place both 200), full pytest
suite green (301 passed, 4 skipped) against the rewritten paths.py.
2026-07-21 15:58:03 -07:00
emi
452788fb18 Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22) 2026-07-21 22:48:59 +00:00
emi
897f413f3c Configurable API base + CORS for the frontend/backend split (Stage 5) (#18) 2026-07-21 20:52:11 +00:00
emi
f21aa2c2e0 Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) 2026-07-21 20:01:30 +00:00
emi
4d86bbce83 Repo-split Stage 3: frontend SSR service (in-repo, not yet live) (#13) 2026-07-21 17:48:55 +00:00
emi
9978754087 Emit a notifier heartbeat to the log stream for the dashboard (#12) 2026-07-21 16:52:55 +00:00
emi
e9e16e6b78 Remove the old scripts/dashboard.py (moved to thermograph-observability) (#11) 2026-07-21 16:32:26 +00:00
emi
f71b23a152 Repo-split Stage 2: SSR content JSON API (#10) 2026-07-21 16:21:28 +00:00
emi
241b87c7b3 Repo-split Stage 1: sever web/'s reverse imports (#9) 2026-07-21 16:09:35 +00:00
emi
7b591439c1 Fix Postgres metrics: dropped counters + invisible notifier heartbeat (#8) 2026-07-21 15:52:28 +00:00
emi
ac69fcea0f Wire account verification end-to-end and fix an RFC 5322 mail bug (#1) 2026-07-21 03:57:20 +00:00
Emi Griffith
913563788b Add a worker scheduler for city warming and IndexNow pings (#242)
warm_cities.py and indexnow.py are one-shot scripts run manually at deploy
today - nothing keeps the curated city set topped up, or pings IndexNow,
between deploys.

Add notifications/scheduler.py: an in-process APScheduler instance driving
two recurring jobs (city warming, daily; IndexNow --if-changed, every 6h by
default), gated by the same leader election as the notifier - only the
process that already won leadership starts it, so the jobs run in exactly
one place under multi-host Swarm rather than once per replica. Neither job
runs immediately on start: warm_cities already runs at deploy time, so an
immediate duplicate on every worker boot/restart would be wasted work.

Extract indexnow.submit_if_changed() from the CLI's --if-changed branch so
the scheduler and the command line share the exact same skip-when-unchanged
logic instead of two copies drifting apart. The CLI's plain (non-flag) path
is unchanged.

APScheduler over a Postgres-native queue: exactly one process ever runs
this, no fan-out/backpressure/dead-letter need, no new infrastructure.
2026-07-21 01:26:39 +00:00
Emi Griffith
1b4a607945 Split web/worker duties with THERMOGRAPH_ROLE (#230)
* Add a Postgres advisory-lock leader election for multi-host deploys

The subscription notifier elects one leader via a host-local flock
(THERMOGRAPH_SINGLETON_LOCK) so multiple uvicorn workers on one host don't
each run it. Under multi-host Swarm that guard is insufficient: each host
would independently elect its own leader, multiplying Open-Meteo quota use
N-fold again.

Add claim_pg(key) alongside the existing claim(lock_path): a cluster-wide
Postgres advisory lock, visible to every host talking to the same database.
The holding connection is dedicated and kept for the process lifetime
(advisory locks are session-scoped); a dead connection is dropped and
re-election retried on the next call.

claim_leader() dispatches between the two mechanisms from env:
THERMOGRAPH_SINGLETON_PG (+ Postgres) -> claim_pg; else
THERMOGRAPH_SINGLETON_LOCK -> claim; else always leader, unchanged. Wired in
at web/app.py in place of the direct claim() call. Off by default, so
today's single-host behavior is unaffected.

* Split web/worker duties with THERMOGRAPH_ROLE

Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.

Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).

Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.

* Retrigger CI (no prior check run was ever recorded for this PR)
2026-07-21 01:23:24 +00:00
Emi Griffith
ab1a4590e0 Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241)
The weather-terms glossary (9 entries) and four static pages' SEO title/
description were hardcoded directly in web/content.py - a 1019-line module
that also owns all SSR rendering logic - mixed in with code that changes on
a completely different cadence and for different reasons.

Add content/glossary.yaml and content/pages.yaml (a new repo-root content/
tree, a sibling of backend/ and frontend/ paths.py resolves the same way -
the seed of a future thermograph-copy repo per the architecture decision
doc's own §4) plus web/content_loader.py: a small loader that validates each
file's shape at load time (required fields present and non-empty, no
duplicate glossary slugs) and fails loudly on a malformed edit rather than
rendering a blank glossary card or an empty <title>. content.py's GLOSSARY
dict and the about/privacy/hub/glossary_index page_title/description
literals now come from the loader.

Scope: only content that is genuinely pure static data with no embedded
template logic. cities_flavor.json (Wikipedia extracts, already its own
generated file) and the homepage's title/description (embedded in
home.html.j2 as Jinja block overrides, a heavily-tested product-critical
template) are deliberately left as they are - a future pass, not required
for this one. UI microcopy bound to frontend logic stays in frontend/,
per the doc's own line between content and frontend.

Verified: content/glossary.yaml generated programmatically from the live
GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte
identical against it; content/pages.yaml's four entries checked field-by-
field against the original hardcoded strings. Full backend suite green (362
passed, 4 skipped) with zero existing test changes needed beyond one
assertion made escaping-aware (a pre-existing Jinja double-escape quirk on
the one title containing "&amp;", intentionally preserved not fixed). Built
and booted the real Docker image: content/ present at /app/content, and
curled /glossary, /glossary/percentine, /about, /privacy from inside the
running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
Emi Griffith
1727ebf60f Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE

Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.

Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).

Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.

* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate

Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:

docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.

TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).

Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.

Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
Emi Griffith
22019bc632 Persist the homepage feed to Postgres instead of a per-replica file (#232)
homepage.json lives on the appdata volume today, written by whichever
process's notifier last refreshed it. Under Swarm each web replica has its
own disk, so a file only one replica's notifier ever writes leaves every
other replica reading a stale or missing feed indefinitely.

On Postgres, refresh()/load() go through store.py's existing derived-payload
table instead (a fixed sentinel key, since the feed isn't cell-scoped) - a
single upsert is already atomic, and every replica reads the same row.
store.IS_POSTGRES is False without THERMOGRAPH_DATABASE_URL, so dev/tests
keep the plain file path entirely unchanged; no existing test needed to
change.

Verified live against real Postgres: refresh() persists into the shared
derived table and load() reads the exact same feed back.
2026-07-21 00:24:17 +00:00
Emi Griffith
4e0a6cfdcb Add entrypoint one-shot migrate mode and a Swarm secrets shim (#231)
Two changes to deploy/entrypoint.sh, both needed before the app can run
under Docker Swarm without re-triggering known incidents:

One-shot migrate mode: `entrypoint.sh migrate` (or THERMOGRAPH_MIGRATE_ONLY=1)
runs the Alembic migration and exits, for a Swarm one-shot task that brings
the schema to head once rather than racing it across every web replica's
boot. RUN_MIGRATIONS=0 skips the inline migrate for a deploy that runs the
one-shot task separately. Default (unset) keeps today's behavior unchanged:
every boot migrates itself before serving.

Secrets shim: the app reads config from os.environ everywhere (an unset
THERMOGRAPH_AUTH_SECRET falls back to a random per-process value; an unset
VAPID key pair is freshly minted) - Swarm `secrets:` mount each secret as a
FILE under /run/secrets/ instead, which the app would never see. Before
alembic/uvicorn, mechanically export each /run/secrets/<name> whose
uppercased name is a THERMOGRAPH_* var, unless already set in the process
env; THERMOGRAPH_DATABASE_URL is built from a postgres_password secret the
same way compose's own interpolation builds it today.

Verified against fake alembic/uvicorn binaries in a throwaway container:
default boot, migrate-only mode (both trigger forms), RUN_MIGRATIONS=0,
secrets populating unset vars, an existing env var beating its secret file,
and no /run/secrets present at all (today's plain compose path, unaffected).
shellcheck clean.
2026-07-21 00:18:05 +00:00
Emi Griffith
86a6e0aaac Add a Postgres advisory-lock leader election for multi-host deploys (#229)
The subscription notifier elects one leader via a host-local flock
(THERMOGRAPH_SINGLETON_LOCK) so multiple uvicorn workers on one host don't
each run it. Under multi-host Swarm that guard is insufficient: each host
would independently elect its own leader, multiplying Open-Meteo quota use
N-fold again.

Add claim_pg(key) alongside the existing claim(lock_path): a cluster-wide
Postgres advisory lock, visible to every host talking to the same database.
The holding connection is dedicated and kept for the process lifetime
(advisory locks are session-scoped); a dead connection is dropped and
re-election retried on the next call.

claim_leader() dispatches between the two mechanisms from env:
THERMOGRAPH_SINGLETON_PG (+ Postgres) -> claim_pg; else
THERMOGRAPH_SINGLETON_LOCK -> claim; else always leader, unchanged. Wired in
at web/app.py in place of the direct claim() call. Off by default, so
today's single-host behavior is unaffected.
2026-07-21 00:07:52 +00:00
Emi Griffith
bf889681f6 Move the climate record from parquet to TimescaleDB hypertables (#227)
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>
2026-07-20 20:15:55 +00:00
Emi Griffith
1fbf3a29b0 Reject a not-yet-backfilled archive instead of caching it as complete (#225)
A self-hosted Open-Meteo instance that isn't backfilled yet answers historical
requests with all-null values (or only its recent sync window), which
_finalize_frame reduces to a near-empty frame. That frame is not None, so the
loader would accept it as source=open-meteo and cache it indefinitely as a
complete record — never falling back to NASA and never self-healing after the
backfill lands. Require at least MIN_ARCHIVE_DAYS before accepting an archive
result; below that, fall through to the NASA backup and don't cache the short
frame. Guards the prod cutover window.
2026-07-20 14:02:36 +00:00
Emi Griffith
4ff12905a1 Self-host the ERA5 archive via Open-Meteo (object storage) (#224)
Get the 45-year historical record off the rate-limited public Open-Meteo
archive API by running a private Open-Meteo instance that serves the
era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the
compressed .om archive in object storage, mounted on the host with rclone.

- climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin
  models=era5_seamless on the archive fetches only, so a self-hosted instance
  serves the same 0.1° resolution; forecast path unchanged. The public API's
  default is already seamless, so dev/beta (URL unset) behave identically.
- docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers
  (era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage
  mount; the overlay points the app at the local instance.
- Makefile: om-up / om-down / om-backfill (one-time full-history backfill).
- Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR,
  and provisions the host rclone systemd mount from the bucket credentials.
- deploy/openmeteo: operator runbook + rclone mount unit template.
2026-07-20 13:16:56 +00:00