All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 49s
PR build (required check) / gate (pull_request) Successful in 2s
The /climate/<city>[/month|/records] pages render from the content derived-store keyed by content_token (PAYLOAD_VER:CONTENT_VER:archive last-date), which turns over ~daily when a cell's archive gains a day. On the first request after each advance the page recomputed a 45-year payload cold. Add warm_cities.warm_content: for each curated city, compute the cheap content token and check whether every content kind (city, 12 months, records) already has a fresh row; cities wholly fresh are skipped without loading the archive, so a re-run is near-instant and only cells whose archive advanced do work. Cache-only like main() — a cell with no cached archive is skipped, never fetched, so warming spends no upstream quota. A per-call limit caps cities (re)built so one pass's wall-clock is bounded; the idempotent skip resumes on the next call. Ride it on the leader-gated notifier loop next to the homepage sweep: _maybe_warm_content runs at most every 30 min, capped at 50 cities per tick, so a tick can't stall the notifier and the ~1000-city set refreshes across a handful of ticks — well inside the <=1-day staleness the token already tolerates.
131 lines
5.7 KiB
Python
131 lines
5.7 KiB
Python
"""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
|