From 5a88f860b1d9380f94bb42a8a8d85d25e3eb1f3d Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Thu, 23 Jul 2026 15:53:59 -0700 Subject: [PATCH] Own /state in the image; lake cache degrades instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh named volume mounted at /state came up root-owned (the image never created the dir, so docker made the mountpoint as root) and the non-root lake service 500'd every /history with EACCES — seen live on the first LAN lake deploy. The image now creates and owns /state, so new volumes inherit uid 10001; and an unwritable cache serves the frame from memory instead of failing, keeping the cache an accelerator rather than a dependency. --- backend/Dockerfile | 4 ++-- backend/lake_app.py | 21 ++++++++++++++++----- backend/tests/web/test_lake_app.py | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 95d28ec..fecc193 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,8 +26,8 @@ RUN chmod +x /app/deploy/entrypoint.sh # Docker seeds them from this image content -- including this ownership -- # so they stay writable. RUN useradd --system --create-home --uid 10001 thermograph \ - && mkdir -p /app/data /app/logs \ - && chown -R thermograph:thermograph /app + && mkdir -p /app/data /app/logs /state \ + && chown -R thermograph:thermograph /app /state USER thermograph WORKDIR /app diff --git a/backend/lake_app.py b/backend/lake_app.py index 6618b35..37eb721 100644 --- a/backend/lake_app.py +++ b/backend/lake_app.py @@ -24,6 +24,7 @@ Scale-out safe: replicas share nothing — each keeps its own cache under THERMOGRAPH_LAKE_CACHE (a per-task local volume), so 1..2 replicas behind the Swarm VIP need no coordination. """ +import io import os import re import threading @@ -31,7 +32,7 @@ import time import polars as pl from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from pydantic import BaseModel from data import era5lake @@ -106,10 +107,20 @@ def history(lat: float, lon: float): df = era5lake._read_bucket(i, j, cfg) except era5lake.LakeUnavailable as e: raise HTTPException(status_code=404, detail=str(e)) from e - os.makedirs(CACHE_DIR, exist_ok=True) - tmp = f"{path}.{os.getpid()}.partial" - df.write_parquet(tmp) - os.replace(tmp, path) # atomic: concurrent requests see whole files only + try: + os.makedirs(CACHE_DIR, exist_ok=True) + tmp = f"{path}.{os.getpid()}.partial" + df.write_parquet(tmp) + os.replace(tmp, path) # atomic: concurrent readers see whole files only + except OSError: + # An unwritable cache (bad volume ownership, full disk) must never + # fail the hot path — serve from memory; the cache is an + # accelerator, not a dependency. + buf = io.BytesIO() + df.write_parquet(buf) + return Response(buf.getvalue(), + media_type="application/vnd.apache.parquet", + headers={"X-Lake-Point": f"{i},{j}"}) return FileResponse(path, media_type="application/vnd.apache.parquet", headers={"X-Lake-Point": f"{i},{j}"}) diff --git a/backend/tests/web/test_lake_app.py b/backend/tests/web/test_lake_app.py index ea832c8..2dd1f1e 100644 --- a/backend/tests/web/test_lake_app.py +++ b/backend/tests/web/test_lake_app.py @@ -69,6 +69,20 @@ def test_history_serves_parquet_and_caches(lake, tmp_path): params={"lat": 51.5074, "lon": -0.1278}).content == r.content +def test_history_serves_from_memory_when_cache_unwritable(lake, tmp_path, monkeypatch): + """An unwritable cache dir (bad volume ownership — seen live) degrades to + serving from memory, never a 500.""" + import lake_app + ro = tmp_path / "ro-cache" / "nested" # parent made read-only below + (tmp_path / "ro-cache").mkdir() + (tmp_path / "ro-cache").chmod(0o555) + monkeypatch.setattr(lake_app, "CACHE_DIR", str(ro)) + r = lake.get("/history", params={"lat": 51.5074, "lon": -0.1278}) + (tmp_path / "ro-cache").chmod(0o755) + assert r.status_code == 200 + assert r.headers["x-lake-point"] == "154,1439" + + def test_history_404_for_point_outside_lake(lake): r = lake.get("/history", params={"lat": -45.0, "lon": 170.0}) assert r.status_code == 404