Pre-warm the SEO content derived-store off-request #49

Merged
admin_emi merged 2 commits from fix/content-prewarm into dev 2026-07-24 19:31:10 +00:00
3 changed files with 265 additions and 0 deletions
Showing only changes of commit 0841390aaf - Show all commits

View file

@ -450,6 +450,37 @@ def _maybe_refresh_homepage() -> None:
pass
# Pre-warming the SEO content derived-store rides this loop too, for the same
# reason the homepage sweep does: it's leader-only (the loop runs on the single
# elected leader) and cache-only, so it spends no upstream quota. The content
# token turns over when a cell's archive gains a day (~1x/day), so after each
# advance the first request to a /climate page would otherwise recompute a
# 45-year payload cold; warming rebuilds those rows off-request. It's paced and
# capped per tick (CONTENT_WARM_MAX_CITIES) so one tick can't stall the notifier,
# and the idempotent skip means a tick after everything is fresh is a cheap
# no-op — the ~1000-city set refreshes across a handful of ticks, well inside the
# <=1-day staleness the content token already tolerates.
CONTENT_WARM_INTERVAL = float(os.environ.get("THERMOGRAPH_CONTENT_WARM_INTERVAL", "1800")) # 30 min
CONTENT_WARM_MAX_CITIES = int(os.environ.get("THERMOGRAPH_CONTENT_WARM_MAX_CITIES", "50"))
_last_content_warm = 0.0
def _maybe_warm_content() -> None:
global _last_content_warm
now = time.time()
if now - _last_content_warm < CONTENT_WARM_INTERVAL:
return
_last_content_warm = now
try:
# Local import: warm_cities is a script-style top-level module (mirrors
# api/internal_routes.py's warm-cities job), so only the leader pays its
# import cost, and it's kept off notify.py's import graph.
import warm_cities
warm_cities.warm_content(limit=CONTENT_WARM_MAX_CITIES)
except Exception: # noqa: BLE001 - warming is best-effort; pages self-heal on request
pass
# The daily "most unusual right now" post to Discord rides this loop too: once per
# day, after the feed is refreshed, leader-only like everything in run_loop. No
# webhook configured => no-op.
@ -489,6 +520,7 @@ def run_loop():
try:
run_pass()
_maybe_refresh_homepage()
_maybe_warm_content()
_maybe_post_discord()
except Exception: # noqa: BLE001 - a bad iteration must never kill the loop
pass

View file

@ -0,0 +1,131 @@
"""warm_cities.warm_content -- the off-request pre-warm that rebuilds the SEO
content derived-store (api/content_routes.py's content-city / content-month /
content-records rows) so the first request after each daily archive advance hits
a warm cache instead of a cold 45-year recompute.
The invariants under test mirror the ones content_routes.py relies on:
* it populates all three kinds under the exact (kind, key, token) the routes use;
* a city with no cached archive is skipped and never triggers an upstream fetch
(warming must not spend quota);
* it is idempotent -- a second run with nothing changed writes zero payloads.
"""
import warm_cities
from api import content_payloads
from api import payloads as cache_ids
from data import cities as cities_mod
from data import climate
from data import grid
from data import store
# A fixed archive last-date -> a stable content token across a test's runs, so the
# idempotency check exercises the "already fresh for this token" fast path.
_MAX_DATE = "2026-07-20"
def _two_cities():
"""Two real curated cities (so the payload builders get the fields they read),
the first with a cached archive, the second without."""
everyone = cities_mod.all_cities()
return everyone[0], everyone[1]
def _wire(monkeypatch, city_with_history, history):
"""Point warm_content's cache-only reads at fixtures and make any upstream
fetch a hard failure, so a test proves warming never reaches the network."""
have_cell = grid.snap(city_with_history["lat"], city_with_history["lon"])["id"]
monkeypatch.setattr(climate, "history_max_date", lambda cell_id: _MAX_DATE)
def fake_cached_history(cell):
return history if cell["id"] == have_cell else None
monkeypatch.setattr(climate, "load_cached_history", fake_cached_history)
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None)
def _no_upstream(*a, **k): # pragma: no cover - only fires on a regression
raise AssertionError("warm_content must not fetch upstream")
monkeypatch.setattr(climate, "get_history", _no_upstream)
monkeypatch.setattr(climate, "get_recent_forecast", _no_upstream)
def test_warms_three_kinds_with_matching_key_and_token(tmp_store, monkeypatch, history):
warm, cold = _two_cities()
_wire(monkeypatch, warm, history)
monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold])
result = warm_cities.warm_content(origin="https://thermograph.org")
origin = "https://thermograph.org"
cell_id = grid.snap(warm["lat"], warm["lon"])["id"]
token = cache_ids.content_token(cell_id)
slug = warm["slug"]
# content-city + content-records under {slug}:{origin}, 12 months under {slug}:{month}
assert store.get_json("content-city", cell_id, f"{slug}:{origin}", token) is not None
assert store.get_json("content-records", cell_id, f"{slug}:{origin}", token) is not None
for month in content_payloads.MONTH_INDEX:
assert store.get_json("content-month", cell_id, f"{slug}:{month}", token) is not None
# 1 city + 1 records + 12 months = 14 payloads for the one city with history.
assert result["built"] == 14
assert result["warmed"] == 1
def test_skips_city_without_cached_history_and_never_fetches(tmp_store, monkeypatch, history):
warm, cold = _two_cities()
_wire(monkeypatch, warm, history)
monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold])
result = warm_cities.warm_content(origin="https://thermograph.org")
# The archive-less city produced no rows (and _no_upstream never fired, or the
# call above would have raised).
cold_cell = grid.snap(cold["lat"], cold["lon"])["id"]
token = cache_ids.content_token(cold_cell)
assert store.get_json("content-city", cold_cell, f"{cold['slug']}:https://thermograph.org", token) is None
assert result["empty"] == 1
def test_idempotent_second_run_writes_nothing(tmp_store, monkeypatch, history):
warm, cold = _two_cities()
_wire(monkeypatch, warm, history)
monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold])
# Spy on put_payload while still persisting, so run 2 sees run 1's rows.
calls = []
real_put = store.put_payload
def spy(kind, cell_id, key, token, payload, cache=True):
calls.append((kind, cell_id, key, token))
return real_put(kind, cell_id, key, token, payload, cache)
monkeypatch.setattr(store, "put_payload", spy)
first = warm_cities.warm_content(origin="https://thermograph.org")
assert len(calls) == 14 # one full city warmed
assert first["built"] == 14
calls.clear()
second = warm_cities.warm_content(origin="https://thermograph.org")
assert calls == [] # nothing rebuilt: every kind still fresh
assert second["built"] == 0
assert second["warmed"] == 0
assert second["skipped"] == 1 # the warm city skipped cheaply
assert second["empty"] == 1 # the archive-less city still empty
def test_limit_caps_cities_built_per_call(tmp_store, monkeypatch, history):
# Two cities, both with a cached archive; limit=1 should build exactly one.
a, b = _two_cities()
have = {grid.snap(a["lat"], a["lon"])["id"], grid.snap(b["lat"], b["lon"])["id"]}
monkeypatch.setattr(climate, "history_max_date", lambda cell_id: _MAX_DATE)
monkeypatch.setattr(climate, "load_cached_history",
lambda cell: history if cell["id"] in have else None)
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None)
monkeypatch.setattr(cities_mod, "all_cities", lambda: [a, b])
result = warm_cities.warm_content(limit=1, origin="https://thermograph.org")
assert result["warmed"] == 1
assert result["built"] == 14

View file

@ -19,13 +19,21 @@ import os
import sys
import time
from api import content_payloads
from api import homepage
from api import payloads as cache_ids
from core import singleton
from data import cities
from data import climate
from data import grid
from data import store
import paths
# The deployment base path, derived exactly as api/content_routes.py does so the
# breadcrumb hrefs / jsonld URLs the warmer bakes match what a live request bakes.
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
# core/singleton.py's flock guard, reused here for cross-*process* (not
# cross-worker) exclusion: the first invocation holds this for its process
# lifetime; a second one (an overlapping deploy) fails the non-blocking flock
@ -67,6 +75,100 @@ def main(limit: int | None = None, pace: float = 2.0) -> None:
print(f"homepage feed: FAILED {e}")
# --- content derived-store pre-warm -------------------------------------------
# The SEO /climate/<city>[/month|/records] pages render their JSON from the
# derived store (api/content_routes.py), keyed by the cheap+stable content token
# (payloads.content_token -> PAYLOAD_VER:CONTENT_VER:archive_last_date). That token
# turns over only when a cell's archive gains a new day (~1x/day), so on the first
# request after each daily advance the page would otherwise recompute a 45-year
# payload cold. Pre-warming rebuilds those rows off-request — cache-only, so it
# never spends upstream quota — so users and crawlers land on a warm cache.
def _content_specs(slug: str, origin: str) -> "list[tuple[str, str, str, int | None]]":
"""The (store-kind, store-key, builder-tag, month_idx) tuples for one city,
matching api/content_routes.py's kinds/keys exactly: content-city and
content-records key on ``{slug}:{origin}`` (the origin is folded in because the
payloads' jsonld.url is origin-qualified), content-month keys on
``{slug}:{month}`` for each of the 12 months."""
specs = [
("content-city", f"{slug}:{origin}", "city", None),
("content-records", f"{slug}:{origin}", "records", None),
]
for month, idx in content_payloads.MONTH_INDEX.items():
specs.append(("content-month", f"{slug}:{month}", "month", idx))
return specs
def _build_content(tag: str, city: dict, history, recent, origin: str, month_idx: "int | None") -> dict:
if tag == "city":
return content_payloads.city_payload(origin, BASE, city, history, recent)
if tag == "records":
return content_payloads.records_payload(origin, BASE, city, history)
return content_payloads.month_payload(BASE, city, history, month_idx)
def warm_content(limit: int | None = None, origin: str | None = None, pace: float = 0.05) -> dict:
"""Rebuild any missing/stale content-page payloads in the derived store.
For each curated city, compute the current content token (cheap no history
load) and check whether every content kind (city, 12 months, records) already
has a row for that token. Cities that are wholly fresh are skipped *without*
loading the archive, so a re-run after everything is warm is near-instant and
only cells whose archive genuinely advanced (new token) do work the same
idempotent contract as ``main``.
Cache-only, exactly like ``main``: the history and recent/forecast bundles are
read from the cache; a cell with no cached archive is skipped rather than
fetched, so warming never spends a single upstream request.
``limit`` caps the number of cities actually (re)built this call (not scanned),
so a caller can bound one pass's wall-clock and rely on the idempotent skip to
resume from where it left off on the next call. Returns a small counts dict.
"""
if origin is None:
origin = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
built = warmed = skipped = empty = failed = 0
for c in cities.all_cities():
if limit is not None and warmed >= limit:
break
slug = c["slug"]
cell = grid.snap(c["lat"], c["lon"])
cell_id = cell["id"]
token = cache_ids.content_token(cell_id)
specs = _content_specs(slug, origin)
# Cheap idempotency gate: which kinds are missing/stale for this token?
# get_payload is a cheap keyed lookup; loading the 45-year archive is not,
# so we defer that until we know at least one kind actually needs building.
stale = [s for s in specs if store.get_payload(s[0], cell_id, s[1], token) is None]
if not stale:
skipped += 1
continue
history = climate.load_cached_history(cell)
if history is None or history.is_empty():
# No cached archive yet -> nothing to build from, and warming must not
# fetch upstream. The cell self-heals on its first live request.
empty += 1
continue
# today_vs_normal on the city payload needs the recent/forecast bundle;
# read it cache-only (never fetch) — None just drops that one block.
recent = climate.load_cached_recent_forecast(cell)
did_work = False
for kind, key, tag, month_idx in stale:
try:
payload = _build_content(tag, c, history, recent, origin, month_idx)
store.put_payload(kind, cell_id, key, token, payload)
built += 1
did_work = True
except Exception: # noqa: BLE001 - one bad payload must not abort the sweep
failed += 1
if did_work:
warmed += 1
if pace:
time.sleep(pace)
return {"warmed": warmed, "built": built, "skipped": skipped, "empty": empty, "failed": failed}
if __name__ == "__main__":
if not singleton.claim(LOCK_PATH):
# Not an error: an overlapping run just means a previous deploy's warm is