Peupler le cache de la webapp à la visualisation, sans rsync

Les images (visualisations, vignettes, sous-tuiles) sont rapatriées depuis
la machine de traitement par HTTP au moment où elles sont affichées, puis
servies depuis le disque : une image absente est téléchargée, une image
périmée ( ?v= plus récent que sa mtime, tuile régénérée) est rafraîchie.
/api/tiles fusionne l'index du worker (cache 60 s) avec le cache local, si
bien que les tuiles non cachées apparaissent quand même sur la carte.
Worker hors ligne : disjoncteur 2 min après 3 échecs, index local seul,
/api/status répond running=null — aucune erreur cliente.
This commit is contained in:
Antoine Jacquin
2026-09-12 19:49:14 +02:00
parent 68272cabf0
commit bde2025714
2 changed files with 357 additions and 14 deletions

View File

@ -51,10 +51,13 @@ import json
import logging
import math
import os
import re
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
@ -132,11 +135,152 @@ if SYNC_CMD:
# statique soit actif même avant la première génération de l'index.
_assets_dir = OUTPUT_DIR / "assets"
_assets_dir.mkdir(parents=True, exist_ok=True)
# --- Cache à la demande : la visualisation peuple le cache local -------------
# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de
# traitement par HTTP : une image absente du disque — ou plus ancienne que la
# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar-
# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur-
# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore).
from fastapi.staticfiles import StaticFiles as _StaticFiles
from starlette.concurrency import run_in_threadpool
_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles")
_FETCH_SEMAPHORE = threading.Semaphore(4)
_FETCH_LOCKS = {}
_FETCH_LOCKS_GUARD = threading.Lock()
_MAX_CACHE_BYTES = 256 * 1024 * 1024 # garde-fou par fichier (dalle AVIF ~35 Mo)
# Disjoncteur : la machine de traitement n'est pas toujours allumée. Après
# quelques échecs consécutifs, les tentatives (index + images) sont suspendues
# 2 min — réponses locales instantanées, aucun martèlement ni erreur cliente.
_WORKER_HEALTH = {"fails": 0, "until": 0.0}
_WORKER_OFFLINE_AFTER = 3
_WORKER_OFFLINE_SECONDS = 120.0
def _worker_offline():
return time.time() < _WORKER_HEALTH["until"]
def _worker_mark(ok):
if ok:
_WORKER_HEALTH.update(fails=0, until=0.0)
else:
_WORKER_HEALTH["fails"] += 1
if (_WORKER_HEALTH["fails"] >= _WORKER_OFFLINE_AFTER
and _WORKER_HEALTH["until"] == 0.0):
_WORKER_HEALTH["until"] = time.time() + _WORKER_OFFLINE_SECONDS
logger.info("Machine de traitement injoignable — "
"rapatriement à la demande suspendu 2 min")
def _version_from_query(scope):
"""Version ?v= (mtime ms de la source) demandée par le client, ou None."""
try:
qs = urllib.parse.parse_qs(scope.get("query_string", b"").decode("ascii"))
v = qs.get("v", [None])[0]
return int(v) if v and v.isdigit() else None
except Exception:
return None
def _safe_rel_path(url_path):
"""Chemin relatif validé (préfixe autorisé, composants sains), ou None."""
rel = url_path.lstrip("/")
prefix = rel.split("/", 1)[0]
if prefix not in _ONDEMAND_PREFIXES:
return None
parts = [p for p in rel.split("/") if p not in ("", ".")]
if not parts or any(p == ".." or not re.fullmatch(r"[A-Za-z0-9_.-]+", p)
for p in parts):
return None
return "/".join(parts)
def _fetch_remote_to_cache(rel_path):
"""Télécharge GENERATION_URL/rel_path vers OUTPUT_DIR/rel_path (atomique)."""
if _worker_offline():
return False
dest = OUTPUT_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".part")
url = f"{GENERATION_URL}/{urllib.parse.quote(rel_path)}"
try:
with _FETCH_SEMAPHORE:
req = urllib.request.Request(url, headers={"User-Agent": "lidar-webapp-cache"})
with urllib.request.urlopen(req, timeout=60) as r, open(tmp, "wb") as f:
total = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
total += len(chunk)
if total > _MAX_CACHE_BYTES:
raise IOError(f"fichier trop volumineux : {rel_path}")
f.write(chunk)
os.replace(tmp, dest)
_worker_mark(True)
logger.info(f"Cache à la demande : {rel_path} rapatrié "
f"({total / 1e6:.1f} Mo)")
return True
except Exception as e:
_worker_mark(False)
tmp.unlink(missing_ok=True)
logger.debug(f"Cache à la demande : échec {rel_path} : {e}")
return False
class _OnDemandStaticFiles(_StaticFiles):
"""Montage statique qui peuple le cache depuis la machine de traitement.
Fichier local absent, ou périmé (?v= plus récent que sa mtime) → téléchar-
gement puis service ; sans GENERATION_URL ou en cas d'échec, comportement
statique normal (404, ou version locale périmée plutôt que rien).
url_prefix : préfixe du montage (ex. visualisations) — dans une app
montée, scope["path"] est le sous-chemin sans préfixe, on le reconstruit.
"""
def __init__(self, *, directory, url_prefix):
super().__init__(directory=directory)
self.url_prefix = url_prefix
async def get_response(self, path, scope):
want = None
if GENERATION_URL:
rel = _safe_rel_path(f"/{self.url_prefix}/{path}")
if rel is not None:
local = OUTPUT_DIR / rel
if not local.is_file():
want = rel
else:
v = _version_from_query(scope)
if v is not None and int(local.stat().st_mtime * 1000) + 500 < v:
want = rel # tuile régénérée sur le worker depuis le cache
if want is not None:
with _FETCH_LOCKS_GUARD:
lock = _FETCH_LOCKS.setdefault(want, threading.Lock())
# Téléchargement bloquant hors de la boucle d'événements ; le
# verrou par fichier déduplique les requêtes concurrentes.
await run_in_threadpool(self._fetch_under_lock, want, lock)
return await super().get_response(path, scope)
@staticmethod
def _fetch_under_lock(rel, lock):
with lock:
return _fetch_remote_to_cache(rel)
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
_dir = OUTPUT_DIR / name
if _dir.exists():
from fastapi.staticfiles import StaticFiles
app.mount(f"/{name}", StaticFiles(directory=str(_dir)), name=name)
_dir.mkdir(parents=True, exist_ok=True)
if name in _ONDEMAND_PREFIXES and GENERATION_URL:
app.mount(f"/{name}",
_OnDemandStaticFiles(directory=str(_dir), url_prefix=name),
name=name)
else:
app.mount(f"/{name}", _StaticFiles(directory=str(_dir)), name=name)
@app.get("/assets/{file_path:path}")
@ -492,8 +636,15 @@ def status(request: Request = None):
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
if GENERATION_URL:
# Webapp légère : l'état vient de la machine de traitement ; le droit
# de génération reste jugé sur l'IP du navigateur (localement).
data = _proxy_api("GET", "/api/status", timeout=10)
# de génération reste jugé sur l'IP du navigateur (localement). Hors
# ligne : running=null (l'interface ignore, aucun faux « terminé »).
try:
data = _proxy_api("GET", "/api/status", timeout=10)
except HTTPException as e:
if e.status_code != 503:
raise
return {"running": None, "distant": True, "worker_offline": True,
"regen_allowed": regen_allowed}
data["distant"] = True
data["regen_allowed"] = regen_allowed
return data
@ -535,25 +686,84 @@ def available_layers():
return found
_REMOTE_INDEX = {"data": None, "fetched": 0.0}
_REMOTE_INDEX_TTL = 60.0
def _tile_entry_key(t):
"""Clé d'unicité d'une tuile affichée (position, résolution, quadrant)."""
return (t.get("col"), t.get("row"), t.get("resolution"),
t.get("sub_i"), t.get("sub_j"))
def _remote_tiles_data():
"""Index de la machine de traitement (cache 60 s), ou None si injoignable.
Une panne ne remonte pas : dernière réponse connue servie telle quelle
(stale-while-error), et le disjoncteur suspend les tentatives.
"""
if not GENERATION_URL or _worker_offline():
return _REMOTE_INDEX["data"]
now = time.time()
if _REMOTE_INDEX["fetched"] > now - _REMOTE_INDEX_TTL:
return _REMOTE_INDEX["data"]
try:
with urllib.request.urlopen(f"{GENERATION_URL}/api/tiles", timeout=4) as r:
data = json.loads(r.read().decode("utf-8"))
_worker_mark(True)
_REMOTE_INDEX.update({"data": data, "fetched": now})
except Exception as e:
_worker_mark(False)
_REMOTE_INDEX["fetched"] = now # ne pas marteler à chaque requête
logger.debug(f"Index distant injoignable : {e}")
return _REMOTE_INDEX["data"]
@app.get("/api/tiles")
def tiles_data(stamp: Optional[float] = None):
"""Données de carte (index_tiles.json) — tuiles visibles sans recharger.
"""Données de carte — cache local + index de la machine de traitement.
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
réécrit ce fichier après chaque tuile terminée. La carte sonde cet
endpoint pendant un run et fusionne les nouveautés. Avec ?stamp=X : la
réponse est allégée ({tiles: null}) tant que le fichier n'a pas changé.
réécrit index_tiles.json après chaque tuile terminée. La carte sonde cet
endpoint et fusionne les nouveautés. Sur la webapp légère, l'index du
worker (cache 60 s) fait autorité : les tuiles absentes du cache local y
figurent quand même — leurs images sont rapatriées à la visualisation par
le montage statique à la demande. Worker hors ligne : cache local seul,
sans erreur. Avec ?stamp=X : réponse allégée ({tiles: null}) si rien n'a
changé (le stamp combine les deux index).
"""
path = OUTPUT_DIR / "index_tiles.json"
try:
current = path.stat().st_mtime
if stamp is not None and abs(current - stamp) < 1e-4:
return {"stamp": current, "tiles": None}
local_stamp = path.stat().st_mtime
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
local_stamp = None
data = None
remote = _remote_tiles_data()
remote_stamp = (remote or {}).get("stamp")
stamps = [s for s in (local_stamp, remote_stamp) if s is not None]
current = max(stamps) if stamps else None
if stamp is not None and current is not None and abs(current - stamp) < 1e-4:
return {"stamp": current, "tiles": None}
if data is None and remote is None:
return {"stamp": None, "tiles": None}
data["stamp"] = current
return data
# L'index distant prime (tuiles régénérées : ?v= neuf → re-téléchargement) ;
# le local complète les positions que le worker n'a plus (historique).
merged = list((remote or {}).get("tiles") or [])
keys = {_tile_entry_key(t) for t in merged}
for t in (data or {}).get("tiles") or []:
k = _tile_entry_key(t)
if k not in keys:
merged.append(t)
keys.add(k)
viz_meta = dict((data or {}).get("viz_meta") or {})
viz_meta.update((remote or {}).get("viz_meta") or {})
return {
"tiles": merged,
"viz_meta": viz_meta,
"stats": (remote or data or {}).get("stats") or {},
"stamp": current,
}
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------