diff --git a/tests/web/test_role.py b/tests/web/test_role.py new file mode 100644 index 0000000..0529807 --- /dev/null +++ b/tests/web/test_role.py @@ -0,0 +1,34 @@ +"""THERMOGRAPH_ROLE: which processes are allowed to own the subscription +notifier (web/worker/all split), plus the /healthz liveness route.""" +from fastapi.testclient import TestClient + +from core import singleton +from web import app as appmod + + +def test_default_role_is_all_and_permits_the_notifier(monkeypatch): + monkeypatch.setattr(appmod, "ROLE", "all") + monkeypatch.setattr(singleton, "claim_leader", lambda: True) + assert appmod._should_run_notifier() is True + + +def test_web_role_never_runs_the_notifier_even_if_it_would_win_leader(monkeypatch): + monkeypatch.setattr(appmod, "ROLE", "web") + monkeypatch.setattr(singleton, "claim_leader", lambda: True) + assert appmod._should_run_notifier() is False + + +def test_worker_role_runs_the_notifier_only_if_it_wins_leader(monkeypatch): + monkeypatch.setattr(appmod, "ROLE", "worker") + monkeypatch.setattr(singleton, "claim_leader", lambda: False) + assert appmod._should_run_notifier() is False + monkeypatch.setattr(singleton, "claim_leader", lambda: True) + assert appmod._should_run_notifier() is True + + +def test_healthz_reports_status_and_configured_role(monkeypatch): + monkeypatch.setattr(appmod, "ROLE", "worker") + client = TestClient(appmod.app) + r = client.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok", "role": "worker"} diff --git a/web/app.py b/web/app.py index 3dee27b..3bed0ae 100644 --- a/web/app.py +++ b/web/app.py @@ -49,6 +49,13 @@ FRONTEND_DIR = paths.FRONTEND_DIR _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" +# Which duties this process performs: "web" (serve requests, no notifier), +# "worker" (own the notifier, still serves requests today — see _lifespan), or +# "all" (both — today's single-process default, so an unset ROLE is unchanged +# behavior). Read once at import: toggling requires a process restart, same as +# every other env-driven constant here (BASE, WORKERS, ...). +ROLE = os.environ.get("THERMOGRAPH_ROLE", "all").strip().lower() + # The PWA manifest is served as a static file; register its media type so # StaticFiles labels it correctly (not in Python's default mimetypes map). mimetypes.add_type("application/manifest+json", ".webmanifest") @@ -120,6 +127,14 @@ def _neighbor_warmer() -> None: _warm_queue.task_done() +def _should_run_notifier() -> bool: + """Whether this process should start the subscription-notifier thread: ROLE must + permit it, and it must win the leader election. Split out from _lifespan so this + decision is unit-testable without booting the full app (DB init, places index, + neighbor warmer) — see tests/web/test_role.py.""" + return ROLE in ("worker", "all") and singleton.claim_leader() + + @contextlib.asynccontextmanager async def _lifespan(app): # Warm the local place-name index (/suggest's typo tolerance) in the @@ -138,8 +153,11 @@ async def _lifespan(app): # it must run in exactly one: elect a leader (see singleton.claim_leader, which # picks a host-local flock or a cluster-wide Postgres advisory lock from env). # Unset (single worker / dev / tests) => always the leader. The neighbor warmer - # above stays per-worker: each drains its own request-fed queue. - if singleton.claim_leader(): + # above stays per-worker: each drains its own request-fed queue. ROLE further + # restricts this to processes that are allowed to own it at all — "web" replicas + # never start it even if they'd otherwise win the leader election, so a stateless + # N-replica web tier can scale without also scaling notifier instances. + if _should_run_notifier(): notify.start() yield notify.stop() @@ -152,6 +170,17 @@ app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan) app.add_middleware(GZipMiddleware, minimum_size=1024) +@app.get("/healthz") +def healthz(): + """Liveness probe: the process is up and serving. Deliberately does no I/O (no + DB query, no upstream call) so it stays cheap and reliable enough for a tight + Swarm/Docker healthcheck interval — readiness (DB reachable, etc.) is a + separate concern the ingress health-gate covers by hitting a real route. Not + under BASE: a fixed, unauthenticated path a healthcheck can hit without + knowing THERMOGRAPH_BASE, on both the web and worker roles.""" + return {"status": "ok", "role": ROLE} + + def _client_ip(request) -> "str | None": """The real client IP: the left-most X-Forwarded-For hop when a proxy fronts us (Caddy on prod sets it), otherwise the direct peer address (LAN dev)."""