metalfrom.eu/apps/crawler/src/health.py
Nicolas Fryder 5237f3666d
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat: validation syntaxique du SQL + supervision des services de fond
Deux angles morts fermés.

1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js)
Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête
syntaxiquement invalide passait tous les tests et n'échouait qu'en production —
c'est précisément ce qui s'était produit avec crawler_pending.
Chaque requête réellement émise par les 28 routes est désormais parsée avec la
grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH
et les casts par type de resolve-conflict. 48 tests, aucun conteneur.
Limite déclarée explicitement : la sémantique n'est pas validée, et deux
requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue
si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il
passe au vert à vide.

2. Supervision des services de fond (migration 015)
Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP
ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un
worker à court de quota Geoapify, restait muet — le seul symptôme était
l'absence de données nouvelles, qu'il fallait remarquer soi-même.

Chaque service écrit un battement de cœur horaire dans service_health :
  - crawler  : base, FlareSolverr joignable, dernier run terminé < 12 h
  - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h
Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL
(> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier
contrôle réussi le ferait sinon passer pour sain indéfiniment.

Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou
silencieux en alerte actionnable.

Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle
surveille. Toute exception devient un échec de sonde, l'écriture du résultat et
la journalisation échouent en silence. Un contrôle de santé qui fait tomber le
crawler serait pire que pas de contrôle.

Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:20:15 +02:00

133 lines
4.8 KiB
Python

"""
Contrôle de santé périodique des services de fond.
Ces services ne sont pas exposés par Traefik : aucune sonde HTTP externe ne peut
les atteindre. Sans ce module, un crawler dont FlareSolverr est injoignable ou
un worker à court de quota reste muet, et le seul symptôme est l'absence de
données nouvelles — qu'il faut remarquer soi-même.
Chaque sonde renvoie (nom, ok, détail). Le résultat agrégé est écrit dans
`service_health` (une ligne par service, écrasée), lu par le dashboard admin.
Aucune sonde ne peut interrompre le service : toute exception est convertie en
échec de sonde. Un contrôle de santé qui fait tomber ce qu'il surveille serait
pire que pas de contrôle du tout.
"""
import logging
import time
log = logging.getLogger(__name__)
# Intervalle minimal entre deux contrôles complets.
DEFAULT_INTERVAL_S = 3600
_last_run = 0.0
def probe(name, fn):
"""Exécute une sonde et convertit toute exception en échec."""
try:
ok, detail = fn()
return name, bool(ok), (detail or "")
except Exception as e: # noqa: BLE001 - une sonde ne doit jamais propager
return name, False, f"{type(e).__name__}: {e}"
def database_probe(get_conn):
"""La base répond-elle ?"""
def _run():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return True, "connexion établie"
return _run
def http_probe(session_get, url, expect_below=500, timeout=10):
"""Une dépendance HTTP répond-elle sans erreur serveur ?"""
def _run():
r = session_get(url, timeout=timeout)
code = getattr(r, "status_code", 0)
return code < expect_below, f"HTTP {code}"
return _run
def freshness_probe(get_conn, sql, max_age_hours, label):
"""Les données progressent-elles ?
`sql` doit renvoyer un unique timestamp (le plus récent). Une base vide
(NULL) n'est pas un échec : c'est un état légitime au premier démarrage.
"""
def _run():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql)
row = cur.fetchone()
latest = row[0] if row else None
if latest is None:
return True, f"{label}: aucune donnée pour l'instant"
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT EXTRACT(EPOCH FROM (now() - %s)) / 3600.0", (latest,)
)
age_h = float(cur.fetchone()[0])
return age_h <= max_age_hours, f"{label}: {age_h:.1f} h (seuil {max_age_hours} h)"
return _run
def write_health(get_conn, service, results):
"""Enregistre le résultat agrégé. N'échoue jamais bruyamment."""
import json
checks = {name: ok for name, ok, _ in results}
failed = [f"{name}{detail}" for name, ok, detail in results if not ok]
ok = not failed
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO service_health (service, ok, checks, error, checked_at)
VALUES (%s, %s, %s::jsonb, %s, now())
ON CONFLICT (service) DO UPDATE SET
ok = EXCLUDED.ok,
checks = EXCLUDED.checks,
error = EXCLUDED.error,
checked_at = EXCLUDED.checked_at
""",
(service, ok, json.dumps(checks), failed[0] if failed else None),
)
except Exception as e: # noqa: BLE001
log.warning(f"[health] écriture impossible: {e}")
return ok, failed
def run_checks(get_conn, service, probes, log_event=None):
"""Exécute toutes les sondes, enregistre le résultat, journalise les échecs."""
results = [probe(name, fn) for name, fn in probes]
ok, failed = write_health(get_conn, service, results)
if failed:
msg = f"[health:{service}] DÉGRADÉ — " + " | ".join(failed)
log.warning(msg)
if log_event:
try:
log_event("warning", msg)
except Exception: # noqa: BLE001, S110 - journaliser un échec de
# journalisation n'apporte rien et risquerait une récursion.
pass
else:
log.info(f"[health:{service}] toutes les sondes sont au vert")
return ok
def maybe_run(get_conn, service, probes, interval_s=DEFAULT_INTERVAL_S, log_event=None, force=False):
"""Point d'entrée depuis une boucle de service : ne contrôle qu'une fois par intervalle."""
global _last_run
now = time.monotonic()
if not force and _last_run and (now - _last_run) < interval_s:
return None
_last_run = now
return run_checks(get_conn, service, probes, log_event=log_event)