diff --git a/tests/web/test_homepage.py b/tests/web/test_homepage.py index 7dd9393..d31a07d 100644 --- a/tests/web/test_homepage.py +++ b/tests/web/test_homepage.py @@ -16,6 +16,7 @@ from fastapi.testclient import TestClient from web import app as appmod from data import cities from data import climate +from data import store from web import content from web import homepage @@ -134,6 +135,41 @@ def test_refresh_writes_atomically(monkeypatch, tmp_path): assert [p.name for p in tmp_path.iterdir()] == ["homepage.json"] +def test_refresh_persists_via_store_on_postgres_not_the_file(monkeypatch, tmp_path): + """On Postgres every web replica has its own disk, so the feed must go through + store.py's shared table instead — and must NOT also write the (per-replica, + stale-to-everyone-else) local file.""" + monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json")) + monkeypatch.setattr(climate, "load_cached_history", lambda cell: None) + monkeypatch.setattr(store, "IS_POSTGRES", True) + calls = [] + monkeypatch.setattr( + store, "put_payload", + lambda kind, cell_id, key, token, payload, **kw: calls.append( + (kind, cell_id, key, token, payload))) + feed = homepage.refresh(limit=3, fetch=0) + assert len(calls) == 1 + kind, cell_id, key, token, payload = calls[0] + assert (kind, cell_id, key) == ("homepage", "_global", "feed") + assert token and payload is feed + assert not (tmp_path / "homepage.json").exists() + + +def test_load_reads_via_store_on_postgres(monkeypatch): + monkeypatch.setattr(store, "IS_POSTGRES", True) + sentinel = {"ranked": [], "date": "2026-01-01"} + monkeypatch.setattr(store, "get_json", lambda *a, **k: sentinel) + assert homepage.load() is sentinel + + +def test_load_on_postgres_handles_absent_or_malformed(monkeypatch): + monkeypatch.setattr(store, "IS_POSTGRES", True) + monkeypatch.setattr(store, "get_json", lambda *a, **k: None) + assert homepage.load() is None + monkeypatch.setattr(store, "get_json", lambda *a, **k: {"no_ranked_key": True}) + assert homepage.load() is None + + def test_refresh_can_be_strictly_cache_only(monkeypatch, tmp_path): """fetch=0 must spend no upstream request at all — that is the contract the test suite and any offline rebuild rely on.""" diff --git a/web/homepage.py b/web/homepage.py index 91dc324..1921b44 100644 --- a/web/homepage.py +++ b/web/homepage.py @@ -31,13 +31,29 @@ from data import cities from data import climate from data import grading from data import grid +from data import store import paths from web.views import OBS_COLS # data/ is the only writable path under the hardened systemd unit -# (ReadWritePaths=/opt/thermograph/data …), so the feed lives there. +# (ReadWritePaths=/opt/thermograph/data …), so the feed lives there when it's a +# file (see the store.IS_POSTGRES split in refresh/load below). FEED_PATH = os.path.join(paths.DATA_DIR, "homepage.json") +# On Postgres, persist the feed via store.py's existing derived-payload table +# instead of the local file: web replicas each have their own disk under Swarm, +# so a file only that replica's own notifier writes would leave every OTHER +# replica reading a stale (or missing) feed forever. The feed isn't cell-scoped, +# so it's stored under a fixed sentinel key rather than a real cell id; the token +# is a constant because this cache is never invalidated by content, only ever +# overwritten by the next refresh (there's nothing else to compare it against). +# dev/tests keep the plain file (store.IS_POSTGRES is False without +# THERMOGRAPH_DATABASE_URL), so today's behavior there is unchanged. +_FEED_STORE_KIND = "homepage" +_FEED_STORE_CELL = "_global" +_FEED_STORE_KEY = "feed" +_FEED_STORE_TOKEN = "v1" + # How many cards the "Unusual right now" strip can show. RANK_LIMIT = 12 @@ -276,8 +292,8 @@ def _refresh_recent(limit: int | None = None, budget: int = 0) -> int: def refresh(limit: int | None = None, fetch: int | None = None) -> dict: - """Rebuild the feed and write it atomically, so a concurrent reader never - sees a half-written file. + """Rebuild the feed and persist it atomically, so a concurrent reader never + sees a half-written result. ``fetch`` caps the recent/forecast top-ups this pass may spend; pass 0 for a strictly cache-only rebuild (what the tests do). @@ -288,6 +304,13 @@ def refresh(limit: int | None = None, fetch: int | None = None) -> dict: ) feed = build(limit=limit) feed["refreshed"] = refreshed + if store.IS_POSTGRES: + # A single upsert is atomic in the DB sense already; store.put_payload is + # fail-soft (a Postgres hiccup drops the write silently, matching every + # other store.py caller), so this never turns a refresh into a hard failure. + store.put_payload(_FEED_STORE_KIND, _FEED_STORE_CELL, _FEED_STORE_KEY, + _FEED_STORE_TOKEN, feed) + return feed path = os.path.abspath(FEED_PATH) os.makedirs(os.path.dirname(path), exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp") @@ -307,7 +330,16 @@ def refresh(limit: int | None = None, fetch: int | None = None) -> dict: def load() -> dict | None: """Read the feed. Returns None when it is missing or unreadable — the homepage renders its frame without live numbers rather than failing, so a - cold checkout with no feed still serves a complete page.""" + cold checkout with no feed still serves a complete page. + + On Postgres this reads the same row every web replica shares (see refresh); + elsewhere it reads the local file, unchanged from before this store split.""" + if store.IS_POSTGRES: + feed = store.get_json(_FEED_STORE_KIND, _FEED_STORE_CELL, _FEED_STORE_KEY, + _FEED_STORE_TOKEN) + if not isinstance(feed, dict) or "ranked" not in feed: + return None + return feed try: with open(os.path.abspath(FEED_PATH), encoding="utf-8") as f: feed = json.load(f)