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:
@ -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 -------------------
|
||||
|
||||
Reference in New Issue
Block a user