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 web import app as appmod
from data import cities from data import cities
from data import climate from data import climate
from data import store
from web import content from web import content
from web import homepage 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"] 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): 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 """fetch=0 must spend no upstream request at all — that is the contract the
test suite and any offline rebuild rely on.""" 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 climate
from data import grading from data import grading
from data import grid from data import grid
from data import store
import paths import paths
from web.views import OBS_COLS from web.views import OBS_COLS
# data/ is the only writable path under the hardened systemd unit # 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") 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. # How many cards the "Unusual right now" strip can show.
RANK_LIMIT = 12 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: def refresh(limit: int | None = None, fetch: int | None = None) -> dict:
"""Rebuild the feed and write it atomically, so a concurrent reader never """Rebuild the feed and persist it atomically, so a concurrent reader never
sees a half-written file. sees a half-written result.
``fetch`` caps the recent/forecast top-ups this pass may spend; pass 0 for a ``fetch`` caps the recent/forecast top-ups this pass may spend; pass 0 for a
strictly cache-only rebuild (what the tests do). 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 = build(limit=limit)
feed["refreshed"] = refreshed 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) path = os.path.abspath(FEED_PATH)
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp") 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: def load() -> dict | None:
"""Read the feed. Returns None when it is missing or unreadable — the """Read the feed. Returns None when it is missing or unreadable — the
homepage renders its frame without live numbers rather than failing, so a 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: try:
with open(os.path.abspath(FEED_PATH), encoding="utf-8") as f: with open(os.path.abspath(FEED_PATH), encoding="utf-8") as f:
feed = json.load(f) feed = json.load(f)