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.
This commit is contained in:
Emi Griffith 2026-07-20 17:24:17 -07:00 committed by GitHub
parent 4e0a6cfdcb
commit 22019bc632
2 changed files with 72 additions and 4 deletions

View file

@ -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."""

View file

@ -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)