Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 KiB
Bootstrap résilient des données et du schéma
Date : 2026-06-12
Branche : feature/78_api
Statut : design approuvé, à implémenter
Problème
L'API et l'appli Web Dash partagent le même process Python (gunicorn app:server).
L'API sera consommée par des clients en production. Or decp.info tombe « de temps
en temps », et comme tout est dans le même process, une chute du Web emporte l'API.
Diagnostic (clé). Les chutes ne sont pas des crashs runtime aléatoires pendant l'ingestion. Ce sont des échecs de bootstrap au déploiement :
- env oubliée lors d'un déploiement (ex.
DATA_FILE_PARQUET_PATHvide) ; DATA_FILE_PARQUET_PATH(désormais une URL data.gouv.fr) injoignable ou pointant vers un parquet absent/invalide à cause d'un souci dansdecp-processing;DATA_SCHEMA_PATH(URL data.gouv.fr) qui renvoie une erreur.
Le process démarre sur des ressources manquantes/invalides, lève une exception
au moment de l'import (src/db.py et src/utils/data.py font leur bootstrap
au niveau module), et meurt au boot — API comprise.
Pourquoi pas « séparer les process » ?
La séparation API / Web protège contre la contagion runtime (un callback Dash qui tue le worker). Elle ne protège pas contre le mode d'échec réel : si les deux process partagent les mêmes ressources de bootstrap (parquet, schéma, env), ils échouent tous les deux au démarrage, de manière identique.
Le levier réel est donc le durcissement du bootstrap avec fallback « last-known-good » : garantir présence + validité des ressources, et sinon repartir sur les dernières ressources fonctionnelles.
La séparation des process reste hors périmètre de ce spec. La couche données
(src/db.py) est déjà process-agnostique et sans dépendance à Dash, donc la
séparation restera bon marché à dégainer plus tard si un vrai crash runtime
touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve.
État actuel du code (post-merge main)
src/db.py
- Bootstrap au niveau module :
DB_PATH = _ensure_database()puis ouverture d'une connexion DuckDB read-only partagée et lecture duschema. build_database()écrit dans un fichier temporaire puisos.replace()atomique : un build qui échoue en cours de route laisse l'ancien DuckDB intact. ✅- Faille :
should_rebuild()appelleget_last_modified(parquet_path)qui fait unhttpx.head(...).headers["last-modified"]sans aucune gestion d'erreur (src/utils/__init__.py:12). URL injoignable, lente, ou sans en-têtelast-modified⇒ exception ⇒ remonte jusqu'à l'import ⇒ mort au démarrage alors qu'un DuckDB valide existe sur disque. - Faille :
_load_source_frame()faitassert os.path.exists(parquet_path)(non-http) etscan_parquet(http) — les deux peuvent lever et ne sont pas rattrapés au niveau de_ensure_database().
src/utils/data.py — get_data_schema()
- Tente l'URL, attrape seulement 4 erreurs httpx (
ReadTimeout,ReadError,ConnectError,ConnectTimeout), sinon fallback surDATA_SCHEMA_LOCAL. - Faille : pas de
raise_for_status(). Quand data.gouv renvoie une erreur HTTP (le cas cité par l'utilisateur),.json()ne contient pas"fields"⇒KeyErrorligne 92, sans fallback local. - Faille : un payload distant valide JSON mais malformé (sans
"fields") plante aussi sans fallback. - Faille : si les deux sources échouent,
original_schema["fields"]⇒KeyErroropaque au lieu d'une erreur claire.
Décisions
- Schéma : URL primaire, cache seul en fallback (on supprime
DATA_SCHEMA_LOCAL). (confirmé) - DuckDB : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé)
- Last-known-good réel du schéma : après un fetch distant réussi, persister le schéma dans un cache local pour que le fallback soit toujours le dernier schéma distant fonctionnel. (confirmé)
Chemin de persistance du schéma : DATA_SCHEMA_CACHE seul
On remplace DATA_SCHEMA_LOCAL (qui pointait, en dev, vers
../decp-processing/dist/schema.json — un fichier cross-repo qu'on ne veut pas
écraser) par un cache unique possédé par l'app.
DATA_SCHEMA_PATH(URL) — source primaire.DATA_SCHEMA_CACHE(nouveau, ex. défaut./schema.cache.json) — écrit après chaque fetch distant réussi, lu en fallback.
Chaîne de résolution : URL → cache → RuntimeError.
Pourquoi c'est suffisant. Le déploiement est en place sur un VM persistant
(ssh → cd /var/www/APP_NAME → git pull → restart systemd), donc le fichier de
cache survit aux déploiements — même garantie de persistance que le DuckDB
réutilisé. Tous les incidents constatés (env oubliée, parquet KO, URL schéma en
erreur) surviennent sur un redéploiement d'un hôte déjà chaud, où le cache a
déjà été écrit par un boot précédent réussi ⇒ couvert.
Seul cas non couvert (assumé) : le cold start absolu — un hôte qui n'a jamais booté avec succès et URL distante down au même instant. Étroit, non-récurrent. Fermable plus tard par une graine commitée in-repo si jamais il se matérialise (YAGNI).
Contraintes :
DATA_SCHEMA_CACHE(./schema.cache.json) doit être.gitignore— sinon legit pulldu déploiement entrerait en conflit. (Commedecp.duckdbaujourd'hui.)- En dev, plus de fallback vers le schéma frais de
decp-processing: on bascule sur le cache (dernier schéma data.gouv). Acceptable, l'URL restant primaire.
Design
Invariant 1 — Bootstrap DuckDB (src/db.py)
Le process démarre tant qu'un DuckDB exploitable existe, quel que soit l'état de la source distante/parquet. Échec dur seulement s'il n'existe aucune base (cold start).
Garde-fou unique dans _ensure_database() :
def _ensure_database() -> Path:
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
lock_path = db_path.with_suffix(".duckdb.lock")
db_exists = db_path.exists()
with open(lock_path, "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
if should_rebuild(db_path, parquet_path):
build_database(db_path)
except Exception as e:
if db_exists:
logger.error(
f"Bootstrap données KO ({e}). "
f"Réutilisation du DuckDB existant : {db_path}"
)
else:
logger.critical("Aucune base DuckDB et reconstruction impossible.")
raise
return db_path
should_rebuild()qui lève (viaget_last_modified()) est désormais rattrapé : base existante ⇒ on la réutilise.build_database()qui lève sur parquet invalide : base existante intacte (atomicité) ⇒ on la réutilise.- Le mode
DEVELOPMENTsort deshould_rebuild()avant tout appel réseau (court-circuitif dev and not force: return False) ⇒ dev inchangé.
Invariant 2 — Schéma (src/utils/data.py)
Un schéma valide non-vide est toujours retourné si une source (distant ou cache) en fournit un. Échec dur seulement si aucune.
def get_data_schema() -> dict:
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None
if raw is not None:
_persist_schema_cache(raw, cache_path)
else:
raw = _load_schema_file(cache_path)
if raw is None:
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
return OrderedDict((c["name"], c) for c in raw["fields"])
Helpers :
_fetch_remote_schema(url) -> dict | None:get(...).raise_for_status().json(), valide"fields" in data, attrape large (httpx.HTTPError,json.JSONDecodeError,KeyError), log l'erreur, renvoieNonesur tout échec._load_schema_file(path) -> dict | None: lit le fichier s'il existe, parse, valide"fields", renvoieNonesinon._persist_schema_cache(data, path): écriture atomique (tmp +os.replace) ; un échec d'écriture est loggé mais non bloquant (le schéma en mémoire reste valide).
Tests (TDD)
Couvrir chaque branche de fallback. Sans dépendre du réseau réel.
Schéma (get_data_schema / helpers) :
- URL OK ⇒ schéma distant retourné et cache écrit.
- URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache.
- URL renvoie un JSON malformé (sans
"fields") ⇒ fallback cache. - URL KO + cache présent ⇒ schéma du cache.
- URL KO + cache absent ⇒
RuntimeErrorclaire. - Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant).
Bootstrap DuckDB (_ensure_database) :
should_rebuildlève + DuckDB existant ⇒ réutilisé, pas d'exception.build_databaselève + DuckDB existant ⇒ réutilisé, pas d'exception.- Échec + aucun DuckDB (cold start) ⇒ ré-lève.
- Cas nominal : rebuild nécessaire et possible ⇒ build effectué.
Mocker get_last_modified / build_database / httpx.get ; utiliser des fichiers
DuckDB et schéma temporaires (tmp_path).
Hors périmètre
- Séparation des process API / Web (reportée — voir plus haut).
- Surveillance / alerting externe (les logs
error/criticalsuffisent pour ce lot). - Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ».
Variables d'environnement
| Variable | Rôle | Changement |
|---|---|---|
DATA_FILE_PARQUET_PATH |
Source parquet (URL ou chemin) | inchangé |
DATA_SCHEMA_PATH |
URL schéma (primaire) | inchangé |
DATA_SCHEMA_LOCAL |
Ancien fichier de secours statique | supprimé |
DATA_SCHEMA_CACHE |
Cache last-known-good du schéma distant | nouveau |
DUCKDB_PATH |
Fichier DuckDB | inchangé |
À faire côté config :
- Ajouter
DATA_SCHEMA_CACHEà.template.env, retirerDATA_SCHEMA_LOCALde.template.env/.env. - Ajouter
schema.cache.json(ou la valeur deDATA_SCHEMA_CACHE) au.gitignore.