Check derived store before loading history on content routes #48
7 changed files with 277 additions and 17 deletions
|
|
@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
|||
|
||||
from api import content_payloads as payloads
|
||||
from api import sitemap as sitemap_mod
|
||||
from api.payloads import history_token
|
||||
from api.payloads import content_token
|
||||
from data import climate
|
||||
from data import cities
|
||||
from data import grid
|
||||
|
|
@ -58,13 +58,21 @@ def _cached(request: Request, kind: str, cell_id: str, key: str, token: str, bui
|
|||
return _json_response(store.put_payload(kind, cell_id, key, token, payload), etag)
|
||||
|
||||
|
||||
def _resolve_city(slug: str):
|
||||
"""(city, cell, history) for a slug, or raise 404 (unknown) / 503 (warming) --
|
||||
mirrors web/content.py's _resolve_city."""
|
||||
def _city_cell(slug: str):
|
||||
"""(city, cell) for a slug, or raise 404 — both steps are cheap (a dict
|
||||
lookup and a pure grid snap) and, crucially, load no history, so the
|
||||
derived-store token can be computed and a cache hit served without ever
|
||||
touching the ~45-year archive."""
|
||||
city = cities.get(slug)
|
||||
if city is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown city.")
|
||||
cell = grid.snap(city["lat"], city["lon"])
|
||||
return city, grid.snap(city["lat"], city["lon"])
|
||||
|
||||
|
||||
def _load_history(cell):
|
||||
"""The cell's ~45-year archive, or raise 503 while it's still warming --
|
||||
the expensive load that used to run on every request. Now called only from
|
||||
the content handlers' build() closures, i.e. only on a derived-store miss."""
|
||||
history = climate.load_cached_history(cell)
|
||||
if history is None or history.is_empty():
|
||||
try:
|
||||
|
|
@ -73,7 +81,7 @@ def _resolve_city(slug: str):
|
|||
history = None
|
||||
if history is None or history.is_empty():
|
||||
raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.")
|
||||
return city, cell, history
|
||||
return history
|
||||
|
||||
|
||||
def _origin(request: Request) -> str:
|
||||
|
|
@ -105,16 +113,17 @@ def content_home(request: Request):
|
|||
|
||||
@router.get("/content/city/{slug}")
|
||||
def content_city(request: Request, slug: str):
|
||||
city, cell, history = _resolve_city(slug)
|
||||
recent = None
|
||||
try:
|
||||
recent = climate.get_recent_forecast(cell)
|
||||
except Exception: # noqa: BLE001 - today_vs_normal degrades to None
|
||||
pass
|
||||
token = history_token(history)
|
||||
city, cell = _city_cell(slug)
|
||||
token = content_token(cell["id"])
|
||||
origin = _origin(request)
|
||||
|
||||
def build():
|
||||
history = _load_history(cell)
|
||||
recent = None
|
||||
try:
|
||||
recent = climate.get_recent_forecast(cell)
|
||||
except Exception: # noqa: BLE001 - today_vs_normal degrades to None
|
||||
pass
|
||||
return payloads.city_payload(origin, BASE, city, history, recent)
|
||||
|
||||
# origin is folded into the cache key (not just slug) because the payload's
|
||||
|
|
@ -131,10 +140,11 @@ def content_city(request: Request, slug: str):
|
|||
def content_city_month(request: Request, slug: str, month: str):
|
||||
if month not in payloads.MONTH_INDEX:
|
||||
raise HTTPException(status_code=404, detail="Unknown month.")
|
||||
city, cell, history = _resolve_city(slug)
|
||||
token = history_token(history)
|
||||
city, cell = _city_cell(slug)
|
||||
token = content_token(cell["id"])
|
||||
|
||||
def build():
|
||||
history = _load_history(cell)
|
||||
return payloads.month_payload(BASE, city, history, payloads.MONTH_INDEX[month])
|
||||
|
||||
return _cached(request, "content-month", cell["id"], f"{slug}:{month}", token, build)
|
||||
|
|
@ -142,11 +152,12 @@ def content_city_month(request: Request, slug: str, month: str):
|
|||
|
||||
@router.get("/content/city/{slug}/records")
|
||||
def content_city_records(request: Request, slug: str):
|
||||
city, cell, history = _resolve_city(slug)
|
||||
token = history_token(history)
|
||||
city, cell = _city_cell(slug)
|
||||
token = content_token(cell["id"])
|
||||
origin = _origin(request)
|
||||
|
||||
def build():
|
||||
history = _load_history(cell)
|
||||
return payloads.records_payload(origin, BASE, city, history)
|
||||
|
||||
# See content_city's comment: origin folded into the cache key for the
|
||||
|
|
|
|||
|
|
@ -73,6 +73,30 @@ def history_token(history) -> str:
|
|||
return f"{PAYLOAD_VER}:{hist_end(history)}"
|
||||
|
||||
|
||||
# The content-shape version for the SEO content pages (/climate/<city>[/month|
|
||||
# /records]). Kept separate from PAYLOAD_VER so a content-only shape change need
|
||||
# not orphan every other kind's cache, and vice-versa. Bump on change.
|
||||
CONTENT_VER = "c1"
|
||||
|
||||
|
||||
def content_token(cell_id: str) -> str:
|
||||
"""Validity for the SEO content-page payloads — cheap AND stable.
|
||||
|
||||
Unlike history_token, this never loads the ~45-year archive: it keys on the
|
||||
cell's newest archived DATE (climate.history_max_date — an indexed
|
||||
``MAX(date)`` on Postgres, a single-column read on the parquet backend), so it
|
||||
survives the hourly tail top-ups (which only refresh intra-day freshness) and
|
||||
turns over only when the archive's last day genuinely advances (≈1×/day). That
|
||||
keeps content pages ≤1 day stale — acceptable for SEO — while sparing every
|
||||
request the full-history load the old token forced even on a cache hit.
|
||||
Fail-soft: a store/DB error yields a 'none' bucket rather than raising."""
|
||||
try:
|
||||
max_date = climate.history_max_date(cell_id)
|
||||
except Exception: # noqa: BLE001 - token computation must never fail a request
|
||||
max_date = None
|
||||
return f"{PAYLOAD_VER}:{CONTENT_VER}:{max_date or 'none'}"
|
||||
|
||||
|
||||
def recent_token(history, cell_id: str) -> str:
|
||||
"""Validity for payloads that also grade the hourly recent/forecast bundle."""
|
||||
return f"{history_token(history)}:{climate.recent_stamp(cell_id)}"
|
||||
|
|
|
|||
|
|
@ -862,6 +862,30 @@ def recent_stamp(cell_id: str) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def history_max_date(cell_id: str) -> "str | None":
|
||||
"""ISO date (YYYY-MM-DD) of the cell's newest cached archived day, or None when
|
||||
nothing is cached — read WITHOUT loading the full multi-decade history.
|
||||
|
||||
Backs the content-page cache token (api/payloads.content_token). On Postgres
|
||||
it's an indexed ``MAX(date)`` over climate_history; on the parquet backend it's
|
||||
the max of the cached file's date column (a single columnar read via
|
||||
``scan_parquet``, not a full frame load). Fail-soft: any error reads as None."""
|
||||
if climate_store.is_postgres():
|
||||
return climate_store.history_max_date(cell_id)
|
||||
path = _cache_path(cell_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
val = pl.scan_parquet(path).select(pl.col("date").max()).collect().item()
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime.datetime): # older files stored date as Datetime
|
||||
val = val.date()
|
||||
return val.isoformat()
|
||||
except Exception: # noqa: BLE001 - a corrupt/absent cache reads as no max date
|
||||
return None
|
||||
|
||||
|
||||
def get_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||
"""Recent observations + forward forecast, with humidity as absolute humidity
|
||||
(g/m³). Thin wrapper over the raw loader (see below)."""
|
||||
|
|
|
|||
|
|
@ -188,6 +188,29 @@ def recent_synced_at(cell_id: str) -> float:
|
|||
return 0.0
|
||||
|
||||
|
||||
def history_max_date(cell_id: str) -> "str | None":
|
||||
"""The ISO date (YYYY-MM-DD) of the cell's newest archived day, or None when
|
||||
there is no cached history (or Postgres is off/unreachable).
|
||||
|
||||
Backs the content-page cache token (api/payloads.content_token), so it must be
|
||||
CHEAP — an indexed ``MAX(date)`` over the ``(cell_id, date)`` primary key (see
|
||||
the 0002 migration), never a full-history load. Fail-soft: any error reads as
|
||||
None, so the caller falls back to a 'none' bucket rather than raising."""
|
||||
try:
|
||||
with _conn() as conn:
|
||||
if conn is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT MAX(date) FROM climate_history WHERE cell_id = %s",
|
||||
(cell_id,),
|
||||
).fetchone()
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
return row[0].isoformat()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def cols_version(cell_id: str) -> int:
|
||||
"""The stored schema version for a cell's history; 0 if absent."""
|
||||
try:
|
||||
|
|
|
|||
130
backend/tests/api/test_content_routes.py
Normal file
130
backend/tests/api/test_content_routes.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Route tests for the SSR content JSON API (api/content_routes.py).
|
||||
|
||||
The point of these is the latency fix: a derived-store cache HIT must be O(1) —
|
||||
it must NOT load the ~45-year archive. So the weather layer is faked with call
|
||||
counters, and the core assertion is that a second identical request loads the
|
||||
history zero more times than the first. ETag/304 revalidation and the 404/payload
|
||||
shapes are covered alongside, against a fresh per-test store.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from web import app as appmod
|
||||
from data import climate
|
||||
from data import cities
|
||||
|
||||
BASE = "/thermograph/api/v2/content"
|
||||
|
||||
CITY = {
|
||||
"slug": "testville",
|
||||
"name": "Testville",
|
||||
"admin1": "Washington",
|
||||
"country": "United States",
|
||||
"country_code": "US",
|
||||
"lat": 47.6062,
|
||||
"lon": -122.3321,
|
||||
"population": 100000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def counts():
|
||||
return {"load_cached_history": 0, "get_history": 0}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, history, recent, counts, tmp_store):
|
||||
"""TestClient with the weather + city layer faked and the history loaders
|
||||
wrapped in counters. tmp_store gives each test a fresh derived store so the
|
||||
miss->hit sequence is deterministic."""
|
||||
def load_cached_history(cell):
|
||||
counts["load_cached_history"] += 1
|
||||
return history.clone()
|
||||
|
||||
def get_history(cell):
|
||||
counts["get_history"] += 1
|
||||
return history.clone(), {"cached": True, "cache_age_days": 3}
|
||||
|
||||
monkeypatch.setattr(climate, "load_cached_history", load_cached_history)
|
||||
monkeypatch.setattr(climate, "get_history", get_history)
|
||||
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
|
||||
# Stable token: content_token() -> payloads -> climate.history_max_date.
|
||||
monkeypatch.setattr(climate, "history_max_date", lambda cell_id: "2026-07-01")
|
||||
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
|
||||
# Only "testville" is a known city; anything else -> 404.
|
||||
monkeypatch.setattr(cities, "get", lambda slug: CITY if slug == "testville" else None)
|
||||
return TestClient(appmod.app)
|
||||
|
||||
|
||||
# --- the regression guard: a cache hit loads no history ----------------------
|
||||
|
||||
def test_cache_hit_does_not_load_history(client, counts):
|
||||
"""First request misses -> builds -> loads history exactly once. A second
|
||||
identical request is served from the derived store and loads history ZERO
|
||||
more times. This is the whole point of the fix."""
|
||||
r1 = client.get(f"{BASE}/city/testville")
|
||||
assert r1.status_code == 200
|
||||
assert counts["load_cached_history"] == 1
|
||||
assert counts["get_history"] == 0 # cached load was non-empty, no live fetch
|
||||
|
||||
r2 = client.get(f"{BASE}/city/testville")
|
||||
assert r2.status_code == 200
|
||||
assert r2.content == r1.content # replayed from the store
|
||||
assert counts["load_cached_history"] == 1 # unchanged: no history load on the hit
|
||||
assert counts["get_history"] == 0
|
||||
|
||||
|
||||
def test_304_revalidation_loads_no_history(client, counts):
|
||||
"""If-None-Match on a stored payload -> 304 with no history load."""
|
||||
r1 = client.get(f"{BASE}/city/testville")
|
||||
assert r1.status_code == 200
|
||||
etag = r1.headers["etag"]
|
||||
before = counts["load_cached_history"]
|
||||
|
||||
r304 = client.get(f"{BASE}/city/testville", headers={"If-None-Match": etag})
|
||||
assert r304.status_code == 304
|
||||
assert r304.headers["etag"] == etag
|
||||
assert counts["load_cached_history"] == before # 304 loaded nothing
|
||||
|
||||
|
||||
def test_month_and_records_hits_skip_history_load(client, counts):
|
||||
"""Same O(1)-hit guarantee for the month and records handlers."""
|
||||
for path in ("month/july", "records"):
|
||||
counts["load_cached_history"] = 0
|
||||
assert client.get(f"{BASE}/city/testville/{path}").status_code == 200
|
||||
assert counts["load_cached_history"] == 1
|
||||
assert client.get(f"{BASE}/city/testville/{path}").status_code == 200
|
||||
assert counts["load_cached_history"] == 1 # second call is a store hit
|
||||
|
||||
|
||||
# --- payload shapes ----------------------------------------------------------
|
||||
|
||||
def test_city_payload_shape(client):
|
||||
body = client.get(f"{BASE}/city/testville").json()
|
||||
assert body["city"]["slug"] == "testville"
|
||||
assert body["canonical_path"] == "/climate/testville"
|
||||
assert len(body["months"]) == 12
|
||||
assert body["today_vs_normal"] is not None # recent forecast folded in
|
||||
|
||||
|
||||
def test_month_payload_shape(client):
|
||||
body = client.get(f"{BASE}/city/testville/month/july").json()
|
||||
assert body["month_slug"] == "july"
|
||||
assert body["canonical_path"] == "/climate/testville/july"
|
||||
|
||||
|
||||
def test_records_payload_shape(client):
|
||||
body = client.get(f"{BASE}/city/testville/records").json()
|
||||
assert body["canonical_path"] == "/climate/testville/records"
|
||||
|
||||
|
||||
# --- 404s --------------------------------------------------------------------
|
||||
|
||||
def test_unknown_slug_is_404(client, counts):
|
||||
for path in ("city/nope", "city/nope/month/july", "city/nope/records"):
|
||||
assert client.get(f"{BASE}/{path}").status_code == 404
|
||||
assert counts["load_cached_history"] == 0 # never reached the archive
|
||||
|
||||
|
||||
def test_unknown_month_is_404(client):
|
||||
assert client.get(f"{BASE}/city/testville/month/smarch").status_code == 404
|
||||
|
|
@ -39,6 +39,34 @@ def test_cache_identity_formats_are_pinned(history):
|
|||
assert payloads.history_token(history) == f"{payloads.PAYLOAD_VER}:{payloads.hist_end(history)}"
|
||||
|
||||
|
||||
def test_content_token_is_stable_across_tail_topups(monkeypatch):
|
||||
"""The content token keys on the archive's newest DATE, not its row count/mtime,
|
||||
so hourly tail top-ups that don't change the max date leave it unchanged — while
|
||||
a genuinely-advanced last day turns it over."""
|
||||
date_box = {"v": "2026-06-15"}
|
||||
monkeypatch.setattr(climate, "history_max_date", lambda cid: date_box["v"])
|
||||
first = payloads.content_token("1_2")
|
||||
assert first == f"{payloads.PAYLOAD_VER}:{payloads.CONTENT_VER}:2026-06-15"
|
||||
# Simulated hourly top-ups: same max date -> identical token every time.
|
||||
assert payloads.content_token("1_2") == first
|
||||
assert payloads.content_token("1_2") == first
|
||||
# The archive's last day advances -> the token turns over.
|
||||
date_box["v"] = "2026-06-16"
|
||||
assert payloads.content_token("1_2") != first
|
||||
assert payloads.content_token("1_2") == f"{payloads.PAYLOAD_VER}:{payloads.CONTENT_VER}:2026-06-16"
|
||||
|
||||
|
||||
def test_content_token_fail_soft_buckets_to_none(monkeypatch):
|
||||
"""A store/DB error (or an uncached cell) never raises — it buckets to 'none'."""
|
||||
monkeypatch.setattr(climate, "history_max_date", lambda cid: None)
|
||||
assert payloads.content_token("1_2") == f"{payloads.PAYLOAD_VER}:{payloads.CONTENT_VER}:none"
|
||||
|
||||
def boom(cid):
|
||||
raise RuntimeError("store down")
|
||||
monkeypatch.setattr(climate, "history_max_date", boom)
|
||||
assert payloads.content_token("1_2") == f"{payloads.PAYLOAD_VER}:{payloads.CONTENT_VER}:none"
|
||||
|
||||
|
||||
def test_recent_token_composes_history_and_stamp(history, monkeypatch):
|
||||
monkeypatch.setattr(climate, "recent_stamp", lambda cid: "stamp")
|
||||
assert payloads.recent_token(history, "1_2") == f"{payloads.history_token(history)}:stamp"
|
||||
|
|
|
|||
|
|
@ -69,6 +69,17 @@ def test_recent_backend_roundtrips_through_parquet(monkeypatch, tmp_path):
|
|||
assert hit is not None and hit[0].height == 3
|
||||
|
||||
|
||||
def test_history_max_date_reads_parquet_tail_without_full_load(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||
cell_id = "9_10"
|
||||
assert climate.history_max_date(cell_id) is None # nothing cached -> None
|
||||
climate._write_history_backed(cell_id, _hist_frame(n=5)) # dates 1995-01-01..05
|
||||
assert climate.history_max_date(cell_id) == "1995-01-05"
|
||||
# A tail top-up that appends a newer day advances the max date.
|
||||
climate._write_history_backed(cell_id, _hist_frame(n=7))
|
||||
assert climate.history_max_date(cell_id) == "1995-01-07"
|
||||
|
||||
|
||||
# --- gated Postgres integration ---------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -140,6 +151,15 @@ def test_pg_recent_stamp_advances_only_on_write(pg_store):
|
|||
assert pg_store.history_synced_at("1_2") == 6000.0
|
||||
|
||||
|
||||
def test_pg_history_max_date(pg_store):
|
||||
assert pg_store.history_max_date("1_2") is None # no rows -> None
|
||||
pg_store.write_history("1_2", _hist_frame(n=5), 1000.0) # dates 1995-01-01..05
|
||||
assert pg_store.history_max_date("1_2") == "1995-01-05"
|
||||
# Another cell's rows must not leak into this cell's max.
|
||||
pg_store.write_history("3_4", _hist_frame(n=9), 1000.0)
|
||||
assert pg_store.history_max_date("1_2") == "1995-01-05"
|
||||
|
||||
|
||||
def test_pg_cols_version_gate(pg_store):
|
||||
pg_store.write_history("1_2", _hist_frame(n=2), 1000.0)
|
||||
assert pg_store.read_history("1_2") is not None
|
||||
|
|
|
|||
Loading…
Reference in a new issue