diff --git a/Dockerfile b/Dockerfile index 7a26418..6270314 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ USER thermograph WORKDIR /app ENV PORT=8080 \ + WORKERS=1 \ THERMOGRAPH_BASE=/ \ PYTHONUNBUFFERED=1 @@ -28,4 +29,12 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1 -CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} +# Worker count is env-driven (mirrors thermograph-backend's Dockerfile/ +# entrypoint WORKERS pattern) so infra can raise it later with no code +# change. Default of 1 preserves today's exact behavior (no --workers was +# ever passed before). api_client's TTL cache and register()'s IndexNow-key +# handling are both in-process only -- multiple workers just each keep their +# own independent cache, and the IndexNow key is fetched fresh FROM the +# backend by every worker's own boot, so there's no cross-worker state to +# diverge -- safe to raise whenever infra wants the throughput. +CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} --workers ${WORKERS:-1} diff --git a/api_client.py b/api_client.py index e5f2ed4..1b1ea9a 100644 --- a/api_client.py +++ b/api_client.py @@ -12,7 +12,9 @@ backend's own derived-store/ETag caching -- this cache saves the network hop entirely; the backend's saves the recompute. """ import os +import threading import time +from collections import OrderedDict import httpx @@ -35,14 +37,60 @@ API_VERSION = os.environ.get("THERMOGRAPH_API_VERSION", "v2") _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") _BASE_PREFIX = f"/{_BASE}" if _BASE else "" +# One pooled client shared by every call, instead of the old top-level +# httpx.get() (a brand-new connection -- fresh TCP, no keep-alive -- per +# request). Mirrors thermograph-backend's web/app.py _frontend_client. +_client = httpx.Client(base_url=API_BASE, timeout=10.0, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)) + _TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default -_cache: dict[str, tuple[float, object]] = {} + +# Bounded LRU: city()/city_records() fold the browser-facing `origin` (client- +# controlled via Host/X-Forwarded-Host, see content.py's _origin) into the +# cache key, so an unbounded dict here is a cheap memory-exhaustion vector -- +# spoof enough distinct Host values and the cache grows forever. An +# OrderedDict is a full LRU with no extra dependency: move_to_end() on every +# hit/write, popitem(last=False) to evict the oldest when over the cap. +_CACHE_MAX_ENTRIES = 2048 +_cache: "OrderedDict[str, tuple[float, object]]" = OrderedDict() +_cache_lock = threading.Lock() + +# Per-key single-flight: without this, N concurrent requests that miss the +# same cache key (cold start, or right after TTL expiry on a hot city) each +# fire their own backend call -- a thundering herd. One lock per in-flight +# key makes every caller but the first block on, then reuse, that first +# caller's result instead of duplicating the request. +_inflight: dict[str, threading.Lock] = {} +_inflight_lock = threading.Lock() + + +def _cache_get(key: str) -> object | None: + with _cache_lock: + hit = _cache.get(key) + if hit is None: + return None + ts, data = hit + if time.time() - ts >= _TTL: + del _cache[key] + return None + _cache.move_to_end(key) + return data + + +def _cache_put(key: str, data: object) -> None: + with _cache_lock: + _cache[key] = (time.time(), data) + _cache.move_to_end(key) + while len(_cache) > _CACHE_MAX_ENTRIES: + _cache.popitem(last=False) # evict least-recently-used def _get(path: str, *, origin: str | None = None) -> object: """GET {API_BASE}{path}, cached for _TTL seconds. Raises httpx.HTTPStatusError on a non-2xx response (including 404/503) so callers can map it the same - way the old in-process code raised HTTPException. + way the old in-process code raised HTTPException; raises httpx.TransportError + (ConnectError, TimeoutException, ...) when the backend can't be reached at + all -- callers map that to a 503 too (see content.py's _raise_api_error). `origin` (e.g. "https://thermograph.org") is the ORIGINAL browser-facing request's origin, not this internal call's -- forwarded as Host/ @@ -53,19 +101,32 @@ def _get(path: str, *, origin: str | None = None) -> object: default same-domain prod topology), and correct when there's more than one (the genuinely-cross-origin capability Stage 5 proves out).""" cache_key = f"{origin}|{path}" if origin else path - now = time.time() - hit = _cache.get(cache_key) - if hit and now - hit[0] < _TTL: - return hit[1] - headers = {} - if origin: - proto, _, host = origin.partition("://") - headers = {"host": host, "x-forwarded-proto": proto} - resp = httpx.get(f"{API_BASE}{_BASE_PREFIX}{path}", timeout=10.0, headers=headers) - resp.raise_for_status() - data = resp.json() - _cache[cache_key] = (now, data) - return data + hit = _cache_get(cache_key) + if hit is not None: + return hit + + with _inflight_lock: + lock = _inflight.get(cache_key) + if lock is None: + lock = threading.Lock() + _inflight[cache_key] = lock + with lock: + hit = _cache_get(cache_key) # someone else may have filled it while we waited + if hit is not None: + return hit + try: + headers = {} + if origin: + proto, _, host = origin.partition("://") + headers = {"host": host, "x-forwarded-proto": proto} + resp = _client.get(f"{_BASE_PREFIX}{path}", headers=headers) + resp.raise_for_status() + data = resp.json() + _cache_put(cache_key, data) + return data + finally: + with _inflight_lock: + _inflight.pop(cache_key, None) def hub() -> dict: diff --git a/app.py b/app.py index a713334..4eb4d61 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,22 @@ from fastapi.staticfiles import StaticFiles import content +# Static assets aren't content-hashed in their filenames, so this has to stay +# short -- it's a revalidation window, not a long-lived immutable cache. Still +# saves a full body re-transfer (the mount's weak ETag otherwise forces a +# conditional GET on every navigation) for anything fetched again within it. +_STATIC_CACHE_CONTROL = "public, max-age=300" + + +class _CachedStaticFiles(StaticFiles): + """StaticFiles with a Cache-Control header stamped onto every response, + including 304s -- see _STATIC_CACHE_CONTROL above.""" + + def file_response(self, *args, **kwargs) -> Response: + response = super().file_response(*args, **kwargs) + response.headers["Cache-Control"] = _STATIC_CACHE_CONTROL + return response + # content's own `paths` (not a fresh `import paths` here) -- in the test # environment, conftest.py's sys.modules swap-then-restore trick means a # top-level `import paths` in THIS module, executed later than content.py's, @@ -58,19 +74,40 @@ def _page(name): reached both directly (Caddy) and through backend's proxy fallback, and only that version prefers X-Forwarded-Host over Host (repo-split Stage 5) -- required for the proxied case to resolve the real browser-facing host - instead of this internal hop's own address.""" + instead of this internal hop's own address. + + The shell file itself (and the verification tags, also constant + for the process's lifetime) is read and prepped once, not on every + request -- only the __ORIGIN__ substitution actually varies per request, + and even that repeats across requests (one canonical origin in the + common topology), so the substituted HTML + its ETag are memoized per + origin instead of re-read-and-re-sha1'd every time.""" path = os.path.join(paths.STATIC_DIR, name) + _template: list[str] = [] # lazy singleton cell, populated on first request + _by_origin: dict[str, tuple[str, str]] = {} # origin_prefix -> (html, etag) + + def _load_template() -> str: + if not _template: + with open(path, encoding="utf-8") as f: + html = f.read() + # Search-engine verification tags (same source as the SSR + # content pages), so the interactive tool's own pages carry them + # too. Also constant for the process's life -- folded in here. + verify = content.head_verify_html() + if verify: + html = html.replace("
", f"\n {verify}", 1) + _template.append(html) + return _template[0] def route(request: Request): - with open(path, encoding="utf-8") as f: - html = f.read() - html = html.replace("__ORIGIN__", f"{content._origin(request)}{content.BASE}") - # Search-engine verification tags (same source as the SSR content - # pages), so the interactive tool's own pages carry them too. - verify = content.head_verify_html() - if verify: - html = html.replace("", f"\n {verify}", 1) - etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' + origin_prefix = f"{content._origin(request)}{content.BASE}" + cached = _by_origin.get(origin_prefix) + if cached is None: + html = _load_template().replace("__ORIGIN__", origin_prefix) + etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' + cached = (html, etag) + _by_origin[origin_prefix] = cached + html, etag = cached if _not_modified(request, etag): return Response(status_code=304, headers={"ETag": etag}) return Response(html, media_type="text/html", headers={"ETag": etag}) @@ -91,4 +128,4 @@ app.add_api_route(f"{content.BASE}/alerts", _page("subscriptions.html"), methods # is a static asset. Registered last so the explicit routes above win. Mounts # must start with "/", so at the root (BASE == "") mount at "/" -- the # earlier routes still win because Starlette matches in registration order. -app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static") +app.mount(content.BASE or "/", _CachedStaticFiles(directory=paths.STATIC_DIR), name="static") diff --git a/content.py b/content.py index 82a539b..6981b25 100644 --- a/content.py +++ b/content.py @@ -41,6 +41,10 @@ _env = Environment( autoescape=select_autoescape(["html", "xml", "j2"]), trim_blocks=True, lstrip_blocks=True, + # Templates only change on a redeploy (a fresh process), not while this + # one is running -- the implicit default (True) stats every template file + # on every single render just to notice a change that can never happen. + auto_reload=False, ) _env.globals["temp_class"] = fmt.temp_class @@ -89,10 +93,15 @@ def _breadcrumb_tuples(breadcrumb: list[dict]) -> list[tuple]: return [(c["name"], c["href"]) for c in breadcrumb] -def _raise_api_error(e: httpx.HTTPStatusError): +def _raise_api_error(e: httpx.HTTPStatusError | httpx.TransportError): """The backend's 404 (unknown city/month) and 503 (warming) map straight - through -- same status codes _resolve_city raised in-process before.""" - raise HTTPException(status_code=e.response.status_code, detail=e.response.text) from e + through -- same status codes _resolve_city raised in-process before. A + TransportError (ConnectError, TimeoutException, ...) never got a response + at all -- a backend blip or restart -- so it becomes a 503 too, rather + than escaping this handler and surfacing as a raw framework 500.""" + if isinstance(e, httpx.HTTPStatusError): + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) from e + raise HTTPException(status_code=503, detail="backend unavailable") from e # --- robots.txt & sitemap.xml --------------------------------------------- @@ -115,7 +124,10 @@ _BOOT_DATE = datetime.date.today().isoformat() def sitemap_xml(request: Request) -> Response: base_url = f"{_origin(request)}{BASE}" - entries = api_client.sitemap() + try: + entries = api_client.sitemap() + except (httpx.HTTPStatusError, httpx.TransportError) as e: + _raise_api_error(e) parts = ['', '