Own /state in the image; lake cache degrades instead of failing (#25)
All checks were successful
secrets-guard / encrypted (push) Successful in 12s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 57s
Deploy backend to LAN dev server / build (push) Successful in 1m13s
Deploy backend to LAN dev server / deploy (push) Successful in 18s

This commit is contained in:
emi 2026-07-23 22:55:41 +00:00
parent a787f79f92
commit 6396d553d2
3 changed files with 32 additions and 7 deletions

View file

@ -26,8 +26,8 @@ RUN chmod +x /app/deploy/entrypoint.sh
# Docker seeds them from this image content -- including this ownership -- # Docker seeds them from this image content -- including this ownership --
# so they stay writable. # so they stay writable.
RUN useradd --system --create-home --uid 10001 thermograph \ RUN useradd --system --create-home --uid 10001 thermograph \
&& mkdir -p /app/data /app/logs \ && mkdir -p /app/data /app/logs /state \
&& chown -R thermograph:thermograph /app && chown -R thermograph:thermograph /app /state
USER thermograph USER thermograph
WORKDIR /app WORKDIR /app

View file

@ -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 THERMOGRAPH_LAKE_CACHE (a per-task local volume), so 1..2 replicas behind the
Swarm VIP need no coordination. Swarm VIP need no coordination.
""" """
import io
import os import os
import re import re
import threading import threading
@ -31,7 +32,7 @@ import time
import polars as pl import polars as pl
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from data import era5lake from data import era5lake
@ -106,10 +107,20 @@ def history(lat: float, lon: float):
df = era5lake._read_bucket(i, j, cfg) df = era5lake._read_bucket(i, j, cfg)
except era5lake.LakeUnavailable as e: except era5lake.LakeUnavailable as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
os.makedirs(CACHE_DIR, exist_ok=True) try:
tmp = f"{path}.{os.getpid()}.partial" os.makedirs(CACHE_DIR, exist_ok=True)
df.write_parquet(tmp) tmp = f"{path}.{os.getpid()}.partial"
os.replace(tmp, path) # atomic: concurrent requests see whole files only 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", return FileResponse(path, media_type="application/vnd.apache.parquet",
headers={"X-Lake-Point": f"{i},{j}"}) headers={"X-Lake-Point": f"{i},{j}"})

View file

@ -69,6 +69,20 @@ def test_history_serves_parquet_and_caches(lake, tmp_path):
params={"lat": 51.5074, "lon": -0.1278}).content == r.content 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): def test_history_404_for_point_outside_lake(lake):
r = lake.get("/history", params={"lat": -45.0, "lon": 170.0}) r = lake.get("/history", params={"lat": -45.0, "lon": 170.0})
assert r.status_code == 404 assert r.status_code == 404