lidar-maps : navigation en cache seule et pyramide entretenue en tâche de fond
Sur une petite machine (Raspberry Pi), LIDAR_TILE_CACHE_ONLY sert les tuiles depuis le cache uniquement (tuile absente = transparente non mémorisable, X-Tile-Pending) et LIDAR_TILE_BACKGROUND fait surveiller les dalles par un sondeur : chaque dalle nouvelle ou régénérée par le worker remet sa pyramide en file, rendue à basse priorité et au ralenti. Scan et état pilotables via /api/map/background, pré-calcul exhaustif toujours via /api/map/warm.
This commit is contained in:
@ -18,6 +18,19 @@ Les tuiles sont rendues à la demande puis mises en cache sur disque
|
||||
(`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un
|
||||
serveur amont : une tuile absente localement y est rapatriée (quelques dizaines
|
||||
de Ko) au lieu de rapatrier les dalles entières.
|
||||
|
||||
Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
|
||||
|
||||
- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile
|
||||
absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par
|
||||
le navigateur) et apparaît après passage de la maintenance ;
|
||||
- `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles
|
||||
(nouvelles ou régénérées) et met leur pyramide à jour en file d'attente,
|
||||
un rendu à la fois, à basse priorité (`os.nice`) et cadencé
|
||||
(`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de
|
||||
toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà
|
||||
fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan,
|
||||
`POST /api/map/warm` reste le pré-calcul manuel exhaustif.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -30,6 +43,9 @@ import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.sax.saxutils as _xml
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -59,6 +75,201 @@ ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").r
|
||||
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
|
||||
_render_sem = threading.Semaphore(TILE_WORKERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance de la pyramide en tâche de fond (petites machines)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge :
|
||||
# la navigation ne rend plus rien (lecture cache seule), une sondeur rescane
|
||||
# les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa
|
||||
# pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la
|
||||
# vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes.
|
||||
# Premier passage = dalle inconnue : toute la pyramide est reconstruite (les
|
||||
# tuiles déjà fraîches ne coûtent qu'un stat).
|
||||
|
||||
def _env_flag(name):
|
||||
"""Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel :
|
||||
testable sans recharger le module."""
|
||||
return (os.environ.get(name, "").strip().lower()
|
||||
in ("1", "true", "yes", "on", "oui"))
|
||||
|
||||
|
||||
def _bg_conf():
|
||||
"""Réglages de la maintenance (relus au démarrage des fils, cf. doc)."""
|
||||
return {
|
||||
"interval": max(10.0, float(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)),
|
||||
"max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
|
||||
"scale": 1 if (os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
|
||||
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp")
|
||||
or "webp").strip(),
|
||||
"pause": max(0.0, float(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
|
||||
"queue_max": max(64, int(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)),
|
||||
}
|
||||
|
||||
|
||||
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
|
||||
"queued": set(), "guard": threading.Lock(), "snapshot": None,
|
||||
"running": False,
|
||||
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
|
||||
"dernier_scan": None, "ajouts_dernier_scan": 0}}
|
||||
|
||||
|
||||
def _bg_enqueue(items):
|
||||
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée."""
|
||||
added = 0
|
||||
with _bg["guard"]:
|
||||
for item in items:
|
||||
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
|
||||
continue
|
||||
_bg["queue"].append(item)
|
||||
_bg["queued"].add(item)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def _bg_pop():
|
||||
with _bg["guard"]:
|
||||
if _bg["queue"]:
|
||||
item = _bg["queue"].popleft()
|
||||
_bg["queued"].discard(item)
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def background_scan(output_dir=None):
|
||||
"""Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file.
|
||||
|
||||
Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le
|
||||
mtime le plus récent des sources de chaque dalle : une dalle régénérée
|
||||
n'invalide que ses propres tuiles (même règle que le cache).
|
||||
"""
|
||||
output_dir = Path(output_dir or OUTPUT_DIR)
|
||||
conf = _bg_conf()
|
||||
snapshot = {}
|
||||
changed = {}
|
||||
for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items():
|
||||
for (col, row), tiers in per_cell.items():
|
||||
newest = 0.0
|
||||
for group in tiers:
|
||||
for src in group:
|
||||
m = src.mtime()
|
||||
if m and m > newest:
|
||||
newest = m
|
||||
snapshot[(layer, col, row)] = newest
|
||||
if (_bg["snapshot"] is not None
|
||||
and _bg["snapshot"].get((layer, col, row)) == newest):
|
||||
continue
|
||||
changed.setdefault(layer, []).append((col, row))
|
||||
_bg["snapshot"] = snapshot
|
||||
items = []
|
||||
for layer, cells in changed.items():
|
||||
for col, row in cells:
|
||||
bbox = tiles_mod.cell_bounds_wgs84(col, row)
|
||||
for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1):
|
||||
if not tiles_mod.zoom_supported(z, conf["scale"]):
|
||||
continue
|
||||
for x, y in tiles_mod.tiles_in_bounds(bbox, z):
|
||||
items.append((layer, z, x, y))
|
||||
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
|
||||
added = _bg_enqueue(items)
|
||||
_bg["stats"]["scans"] += 1
|
||||
_bg["stats"]["dernier_scan"] = time.time()
|
||||
_bg["stats"]["ajouts_dernier_scan"] = added
|
||||
cells_changed = sum(len(v) for v in changed.values())
|
||||
if items:
|
||||
logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) "
|
||||
f"modifiée(s), {added} tuile(s) en file")
|
||||
return added
|
||||
|
||||
|
||||
def _bg_process_one():
|
||||
"""Traite une tuile de la file. Vrai si elle a dû être rendue."""
|
||||
item = _bg_pop()
|
||||
if item is None:
|
||||
return False
|
||||
layer, z, x, y = item
|
||||
conf = _bg_conf()
|
||||
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y,
|
||||
conf["scale"], conf["fmt"])
|
||||
if state != "pending":
|
||||
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1
|
||||
return False
|
||||
tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"])
|
||||
_bg["stats"]["rendues"] += 1
|
||||
return True
|
||||
|
||||
|
||||
def _bg_worker():
|
||||
try:
|
||||
os.nice(10) # consommation minimale : le rendu cède aux autres usages
|
||||
except (AttributeError, OSError): # pragma: no cover — nice indisponible
|
||||
pass
|
||||
while not _bg["stop"].is_set():
|
||||
try:
|
||||
rendered = _bg_process_one()
|
||||
except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file
|
||||
logger.warning(f"Maintenance des tuiles : rendu impossible ({e})")
|
||||
rendered = False
|
||||
if rendered:
|
||||
_bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause
|
||||
else:
|
||||
_bg["stop"].wait(0.5)
|
||||
|
||||
|
||||
def _bg_poller():
|
||||
while not _bg["stop"].is_set():
|
||||
try:
|
||||
background_scan()
|
||||
except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL
|
||||
logger.warning(f"Maintenance des tuiles : scan impossible ({e})")
|
||||
_bg["stop"].wait(_bg_conf()["interval"])
|
||||
|
||||
|
||||
def start_background():
|
||||
"""Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés."""
|
||||
if _bg["running"]:
|
||||
return
|
||||
_bg["stop"].clear()
|
||||
_bg["running"] = True
|
||||
threads = [threading.Thread(target=_bg_poller, daemon=True,
|
||||
name="lidar-bg-scan")]
|
||||
threads += [threading.Thread(target=_bg_worker, daemon=True,
|
||||
name=f"lidar-bg-render-{i}")
|
||||
for i in range(TILE_WORKERS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
_bg["threads"] = threads
|
||||
conf = _bg_conf()
|
||||
logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} "
|
||||
f"@{conf['scale']}x {conf['fmt']}, scan toutes les "
|
||||
f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s "
|
||||
f"entre rendus")
|
||||
|
||||
|
||||
def stop_background():
|
||||
_bg["stop"].set()
|
||||
_bg["running"] = False
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _transparent_tile(scale=1, fmt="png"):
|
||||
return tiles_mod.transparent_tile(scale, fmt)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app):
|
||||
if _env_flag("LIDAR_TILE_BACKGROUND"):
|
||||
start_background()
|
||||
yield
|
||||
stop_background()
|
||||
|
||||
|
||||
_LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
_NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$")
|
||||
_MAX_UPSTREAM_BYTES = 8 * 1024 * 1024
|
||||
@ -70,7 +281,7 @@ if not logger.handlers:
|
||||
logger.addHandler(_h)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="Carte LiDAR — tuiles XYZ")
|
||||
app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan)
|
||||
|
||||
# Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli
|
||||
# (dev, paquet installé sans bake).
|
||||
@ -255,14 +466,28 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
|
||||
if not (0 <= x < n and 0 <= y < n):
|
||||
raise HTTPException(404, "tuile hors grille")
|
||||
|
||||
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
|
||||
empty = data is None
|
||||
if empty:
|
||||
data = tiles_mod.transparent_tile(scale, fmt)
|
||||
if _env_flag("LIDAR_TILE_CACHE_ONLY"):
|
||||
# Mode cache seule : aucune navigation ne déclenche de rendu (ni amont).
|
||||
data, state = await run_in_threadpool(
|
||||
tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt)
|
||||
pending = state == "pending"
|
||||
empty = state == "empty"
|
||||
else:
|
||||
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
|
||||
pending = False
|
||||
empty = data is None
|
||||
if empty or pending:
|
||||
data = _transparent_tile(scale, fmt)
|
||||
headers = dict(_cors_headers())
|
||||
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
|
||||
else "public, max-age=300, must-revalidate")
|
||||
headers["X-Tile-Empty"] = "1" if empty else "0"
|
||||
if pending:
|
||||
# Tuile à venir (rendue par la maintenance) : jamais mémorisée par le
|
||||
# navigateur, la prochaine navigation la redemande et la trouve prête.
|
||||
headers["Cache-Control"] = "no-store"
|
||||
headers["X-Tile-Pending"] = "1"
|
||||
else:
|
||||
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
|
||||
else "public, max-age=300, must-revalidate")
|
||||
headers["X-Tile-Empty"] = "1" if (empty or pending) else "0"
|
||||
return Response(content=data, media_type=_MEDIA[fmt], headers=headers)
|
||||
|
||||
|
||||
@ -586,6 +811,24 @@ def map_warm_status():
|
||||
return dict(_warm)
|
||||
|
||||
|
||||
@app.get("/api/map/background")
|
||||
def background_status():
|
||||
"""État de la maintenance de fond : file, compteurs, configuration."""
|
||||
with _bg["guard"]:
|
||||
queued = len(_bg["queue"])
|
||||
return {"active": _bg["running"], "en_file": queued,
|
||||
"configuration": _bg_conf(), "stats": dict(_bg["stats"])}
|
||||
|
||||
|
||||
@app.post("/api/map/background")
|
||||
def background_trigger():
|
||||
"""Force un scan immédiat des dalles (rendu ensuite en tâche de fond)."""
|
||||
if not _env_flag("LIDAR_TILE_BACKGROUND"):
|
||||
raise HTTPException(409, "maintenance de fond inactive "
|
||||
"(LIDAR_TILE_BACKGROUND)")
|
||||
return {"tuiles_en_file": background_scan()}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}
|
||||
|
||||
Reference in New Issue
Block a user