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:
parent
4e0a6cfdcb
commit
22019bc632
2 changed files with 72 additions and 4 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue