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
187 lines
7.6 KiB
Python
187 lines
7.6 KiB
Python
"""The lake service: low-latency SQL-ish reads over the ERA5 bucket (prod-only).
|
|
|
|
A small FastAPI app run as the Swarm ``lake`` service (same backend image,
|
|
``THERMOGRAPH_ROLE=lake`` — the entrypoint launches this module instead of the
|
|
web app; no database, no migrations). Web replicas call it instead of touching
|
|
the bucket themselves, so a cell's history costs one LAN round-trip against a
|
|
warm disk cache instead of an S3 fetch, and analytical queries get
|
|
partition-pruned scans without shipping S3 credentials to every web task.
|
|
|
|
Endpoints:
|
|
- ``GET /healthz`` liveness (also reports manifest row count)
|
|
- ``GET /history?lat=&lon=`` the hot path: nearest ERA5 point's full daily
|
|
history as parquet bytes, disk-cached per point
|
|
(a plain single-file read — no engine needed)
|
|
- ``POST /query {"sql": ..}`` SELECT-only SQL over two views: ``era5_daily``
|
|
(the hive table, partition-pruned on
|
|
tile/year/month predicates) and ``manifest``.
|
|
Runs on DuckDB — a real lake engine (full SQL,
|
|
parallel scans, hive + row-group pushdown); the
|
|
httpfs extension is baked into the image so S3
|
|
needs no runtime extension fetch.
|
|
|
|
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
|
|
import time
|
|
|
|
import polars as pl
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse, Response
|
|
from pydantic import BaseModel
|
|
|
|
from data import era5lake
|
|
|
|
CACHE_DIR = os.environ.get("THERMOGRAPH_LAKE_CACHE", "/state/lake-cache")
|
|
MANIFEST_TTL = 6 * 3600 # re-pull the manifest at most this often
|
|
QUERY_ROW_CAP = 10_000 # /query responses are JSON; keep them bounded
|
|
|
|
app = FastAPI(title="thermograph-lake")
|
|
|
|
_manifest_lock = threading.Lock()
|
|
_manifest: "pl.DataFrame | None" = None
|
|
_manifest_at = 0.0
|
|
|
|
|
|
def _source():
|
|
"""(mode, cfg) for this process: a local lake dir (tests/dev) or the bucket."""
|
|
if era5lake.local_dir():
|
|
return "local", None
|
|
cfg = era5lake.s3_config()
|
|
return ("bucket", cfg) if cfg else ("none", None)
|
|
|
|
|
|
def _manifest_frame() -> "pl.DataFrame | None":
|
|
global _manifest, _manifest_at
|
|
with _manifest_lock:
|
|
if _manifest is not None and time.time() - _manifest_at < MANIFEST_TTL:
|
|
return _manifest
|
|
mode, cfg = _source()
|
|
try:
|
|
if mode == "local":
|
|
df = pl.read_parquet(
|
|
os.path.join(era5lake.local_dir(), era5lake.MANIFEST_KEY))
|
|
elif mode == "bucket":
|
|
df = pl.read_parquet(
|
|
f"s3://{cfg['bucket']}/{era5lake.MANIFEST_KEY}",
|
|
storage_options=era5lake.storage_options(cfg))
|
|
else:
|
|
return None
|
|
except Exception: # noqa: BLE001 - empty/unreachable lake: serve health, 404 reads
|
|
return None
|
|
_manifest, _manifest_at = df, time.time()
|
|
return df
|
|
|
|
|
|
@app.get("/healthz")
|
|
def healthz():
|
|
mode, _ = _source()
|
|
m = _manifest_frame()
|
|
return {"status": "ok", "source": mode,
|
|
"points": None if m is None else m.height}
|
|
|
|
|
|
@app.get("/history")
|
|
def history(lat: float, lon: float):
|
|
"""One ERA5 point's full daily history, as parquet bytes (cached on disk —
|
|
the cache file IS the response body, so a warm hit is a sendfile)."""
|
|
i, j = era5lake.to_idx(lat, lon)
|
|
path = os.path.join(CACHE_DIR, f"p{i}_{j}.parquet")
|
|
if not os.path.exists(path):
|
|
mode, cfg = _source()
|
|
if mode == "none":
|
|
raise HTTPException(status_code=503, detail="lake not configured")
|
|
m = _manifest_frame()
|
|
if m is not None and m.filter(
|
|
(pl.col("lat_idx") == i) & (pl.col("lon_idx") == j)).is_empty():
|
|
raise HTTPException(status_code=404, detail="point not in the lake")
|
|
try:
|
|
if mode == "local":
|
|
df = era5lake._read_local(i, j)
|
|
else:
|
|
df = era5lake._read_bucket(i, j, cfg)
|
|
except era5lake.LakeUnavailable as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
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}"})
|
|
|
|
|
|
class Query(BaseModel):
|
|
sql: str
|
|
|
|
|
|
_FORBIDDEN = re.compile(
|
|
r"\b(insert|update|delete|drop|create|alter|attach|copy|truncate|install|load"
|
|
r"|export|import|pragma|set|call)\b", re.I)
|
|
|
|
|
|
def _connect():
|
|
"""A fresh in-memory DuckDB with the lake views registered. Per-request:
|
|
connections are cheap, and a throwaway one means a query can't leave state
|
|
behind for the next. Returns None when no lake is configured."""
|
|
import duckdb # noqa: PLC0415 - only the lake role pays the import
|
|
|
|
mode, cfg = _source()
|
|
if mode == "local":
|
|
daily_glob = os.path.join(era5lake.local_dir(), era5lake.PREFIX,
|
|
"daily/*/*/*/*.parquet")
|
|
manifest_src = os.path.join(era5lake.local_dir(), era5lake.MANIFEST_KEY)
|
|
elif mode == "bucket":
|
|
daily_glob = f"s3://{cfg['bucket']}/{era5lake.PREFIX}/daily/*/*/*/*.parquet"
|
|
manifest_src = f"s3://{cfg['bucket']}/{era5lake.MANIFEST_KEY}"
|
|
else:
|
|
return None
|
|
con = duckdb.connect()
|
|
if mode == "bucket":
|
|
con.execute("LOAD httpfs") # baked into the image at build time
|
|
host = cfg["endpoint"].split("://", 1)[-1]
|
|
con.execute(f"SET s3_endpoint='{host}'")
|
|
con.execute("SET s3_url_style='path'")
|
|
con.execute(f"SET s3_region='{cfg['region']}'")
|
|
con.execute("SET s3_access_key_id=?", [cfg["access_key"]])
|
|
con.execute("SET s3_secret_access_key=?", [cfg["secret_key"]])
|
|
con.execute(f"""
|
|
CREATE VIEW era5_daily AS
|
|
SELECT * FROM read_parquet('{daily_glob}', hive_partitioning=1)""")
|
|
con.execute(f"CREATE VIEW manifest AS SELECT * FROM read_parquet('{manifest_src}')")
|
|
return con
|
|
|
|
|
|
@app.post("/query")
|
|
def query(q: Query):
|
|
sql = q.sql.strip().rstrip(";").strip()
|
|
if not sql.lower().startswith(("select", "with")) or ";" in sql \
|
|
or _FORBIDDEN.search(sql):
|
|
raise HTTPException(status_code=400,
|
|
detail="only a single SELECT statement is allowed")
|
|
con = _connect()
|
|
if con is None:
|
|
raise HTTPException(status_code=503, detail="lake not configured")
|
|
try:
|
|
out = con.execute(
|
|
f"SELECT * FROM ({sql}) LIMIT {QUERY_ROW_CAP}").fetchall()
|
|
cols = [d[0] for d in con.description]
|
|
except Exception as e: # noqa: BLE001 - surface the engine's message to the caller
|
|
raise HTTPException(status_code=400, detail=f"query failed: {e}") from e
|
|
finally:
|
|
con.close()
|
|
return {"columns": cols, "rows": out, "row_cap": QUERY_ROW_CAP}
|