refactor: module de santé partagé, supervision complète, endpoints morts traités

health.py existait en deux copies rigoureusement identiques — crawler et
geocoder — dont une seule était couverte par des tests. Rien ne signalait une
divergence : une correction appliquée d'un seul côté serait passée inaperçue.
Le module part dans libs/bm_health.py, embarqué dans les images via un contexte
de build ramené à la racine du dépôt, et rendu importable en local par les
conftest.py. Un .dockerignore racine évite que node_modules parte dans les
images au passage. Les deux images ont été reconstruites et le module vérifié
importable à l'exécution dans chacune.

La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun
n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de
l'admin ne pouvait donc rien signaler — y compris quand le service était mort.
Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec
des sondes propres à leur rôle (clé API, progression de leur file).

Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle.
/admin/api/locations/reset-llm était pourtant le seul moyen de relancer les
lieux passés en 'manual' après épuisement des tentatives LLM : la
fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la
zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec
/admin/api/live et disparaît, avec ses tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-22 14:41:59 +02:00
parent fcb20e2e11
commit e35e3d6de3
18 changed files with 111 additions and 168 deletions

View file

@ -259,6 +259,7 @@ async function renderPilotage(token = renderToken) {
<button class="btn btn-danger" data-danger="reset-all">🔥 Réinitialiser tout le géocodage</button> <button class="btn btn-danger" data-danger="reset-all">🔥 Réinitialiser tout le géocodage</button>
<button class="btn btn-danger" data-danger="purge-nominatim">🧹 Purger le cache Nominatim</button> <button class="btn btn-danger" data-danger="purge-nominatim">🧹 Purger le cache Nominatim</button>
<button class="btn btn-danger" data-danger="requeue-all"> Remettre TOUT en file (dont « done »)</button> <button class="btn btn-danger" data-danger="requeue-all"> Remettre TOUT en file (dont « done »)</button>
<button class="btn btn-danger" data-danger="reset-llm">🤖 Relancer les lieux abandonnés par le LLM</button>
</div> </div>
<div id="p-danger-feedback" class="feedback" role="status"></div> <div id="p-danger-feedback" class="feedback" role="status"></div>
</details> </details>
@ -362,6 +363,14 @@ const DANGER_ACTIONS = {
body: { include_done: true }, body: { include_done: true },
confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?", confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?",
}, },
// Cette route existait, était testée, et n'était appelée par aucun bouton :
// c'était pourtant le seul moyen de relancer les lieux passés en 'manual'
// après épuisement des tentatives LLM. La fonctionnalité existait sans que
// personne puisse l'atteindre.
"reset-llm": {
url: "/admin/api/locations/reset-llm",
confirm: "Relancer les lieux que le LLM n'a pas su résoudre ?\n\nIls repartent en file de géocodage — appels Geoapify et Groq facturés.",
},
}; };
function wireDangerZone() { function wireDangerZone() {

View file

@ -356,21 +356,6 @@ export default async function adminRoutes(fastify, opts) {
} }
}); });
// ------------------------------------------------------------------
// Checkpoints
// (l'historique des crawl_run est exposé de façon unifiée par
// GET /admin/api/activity, avec le détail logs via GET /admin/api/logs?run_id=)
// ------------------------------------------------------------------
fastify.get("/admin/api/crawl-checkpoints", async (req, reply) => {
try {
const r = await pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`);
return { ok: true, items: r.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur checkpoints" });
}
});
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Logs crawler // Logs crawler
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -34,7 +34,6 @@ describe("routes de lecture", () => {
const READ_ROUTES = [ const READ_ROUTES = [
"/admin/api/stats", "/admin/api/stats",
"/admin/api/queue", "/admin/api/queue",
"/admin/api/crawl-checkpoints",
"/admin/api/logs", "/admin/api/logs",
"/admin/api/geocoding", "/admin/api/geocoding",
"/admin/api/llm", "/admin/api/llm",

View file

@ -139,7 +139,7 @@ describe.runIf(!process.env.SKIP_INTEGRATION)("SQL validé par PostgreSQL", () =
const ADMIN_GET = [ const ADMIN_GET = [
"/admin/api/stats", "/admin/api/queue", "/admin/api/bands", "/admin/api/stats", "/admin/api/queue", "/admin/api/bands",
"/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc", "/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc",
"/admin/api/bands/1", "/admin/api/crawl-checkpoints", "/admin/api/logs", "/admin/api/bands/1", "/admin/api/logs",
"/admin/api/logs?level=error&run_id=1&min_id=5", "/admin/api/geocoding", "/admin/api/logs?level=error&run_id=1&min_id=5", "/admin/api/geocoding",
"/admin/api/llm", "/admin/api/llm?model=x&only_null=1&q=oslo", "/admin/api/llm", "/admin/api/llm?model=x&only_null=1&q=oslo",
"/admin/api/job-triggers", "/admin/api/live", "/admin/api/job-triggers", "/admin/api/live",

View file

@ -106,7 +106,6 @@ describe("SQL des routes admin", () => {
{ url: "/admin/api/bands" }, { url: "/admin/api/bands" },
{ url: "/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc&page=2" }, { url: "/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc&page=2" },
{ url: "/admin/api/bands/1" }, { url: "/admin/api/bands/1" },
{ url: "/admin/api/crawl-checkpoints" },
{ url: "/admin/api/logs" }, { url: "/admin/api/logs" },
{ url: "/admin/api/logs?level=error&run_id=1&min_id=5&since=2026-01-01" }, { url: "/admin/api/logs?level=error&run_id=1&min_id=5&since=2026-01-01" },
{ url: "/admin/api/geocoding" }, { url: "/admin/api/geocoding" },

View file

@ -1,10 +1,13 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY apps/crawler/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src # Contexte de build = racine du depot, pour embarquer le module partage.
COPY libs/ ./libs/
COPY apps/crawler/src ./src
ENV PYTHONPATH=/app/libs
# Utilisateur non-root (le crawler parse du HTML tiers avec lxml) # Utilisateur non-root (le crawler parse du HTML tiers avec lxml)
RUN useradd -m -u 1000 crawler RUN useradd -m -u 1000 crawler

View file

@ -22,6 +22,7 @@ import time
from datetime import UTC, datetime from datetime import UTC, datetime
import schedule import schedule
from bm_health import database_probe, freshness_probe, http_probe, maybe_run
from .config import ( from .config import (
ENRICH_LIMIT, ENRICH_LIMIT,
@ -33,7 +34,6 @@ from .config import (
) )
from .db import claim_job_trigger, finish_job_trigger, get_checkpoint, recover_stuck_runs from .db import claim_job_trigger, finish_job_trigger, get_checkpoint, recover_stuck_runs
from .flaresolverr import FlareSolverr from .flaresolverr import FlareSolverr
from .health import database_probe, freshness_probe, http_probe, maybe_run
from .jobs import run_enrich, run_full_crawl, run_incremental from .jobs import run_enrich, run_full_crawl, run_incremental
from .ma_http import MASession from .ma_http import MASession

View file

@ -1,3 +1,10 @@
# Le module de sante est partage entre les apps (libs/bm_health.py) : en
# Docker il arrive via PYTHONPATH, ici on l'ajoute au chemin d'import.
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "libs"))
import os import os
import sys import sys
from pathlib import Path from pathlib import Path

View file

@ -8,9 +8,9 @@ de contrôle du tout — d'où le nombre de cas d'échec vérifiés ici.
import contextlib import contextlib
import bm_health as health
import pytest import pytest
from src import health from bm_health import (
from src.health import (
database_probe, database_probe,
freshness_probe, freshness_probe,
http_probe, http_probe,

View file

@ -3,11 +3,13 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Copier et installer les dépendances en premier (meilleur cache Docker) # Copier et installer les dépendances en premier (meilleur cache Docker)
COPY requirements.txt . COPY apps/geocoder/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copier uniquement le code de l'application # Contexte de build = racine du depot, pour embarquer le module partage.
COPY src ./src COPY libs/ ./libs/
COPY apps/geocoder/src ./src
ENV PYTHONPATH=/app/libs
# Utilisateur non-root # Utilisateur non-root
RUN useradd -m -u 1000 geocoder RUN useradd -m -u 1000 geocoder

View file

@ -21,6 +21,7 @@ import signal
import time import time
import psycopg2 import psycopg2
from bm_health import database_probe, freshness_probe, maybe_run
from parser import ( from parser import (
COUNTRY_CENTROIDS, COUNTRY_CENTROIDS,
COUNTRY_NAME_TO_ISO2, COUNTRY_NAME_TO_ISO2,
@ -254,6 +255,26 @@ def main():
print(f"[enqueue] daemon démarré, poll {POLL_INTERVAL}s, auto toutes les {AUTO_INTERVAL_MIN}min") print(f"[enqueue] daemon démarré, poll {POLL_INTERVAL}s, auto toutes les {AUTO_INTERVAL_MIN}min")
# La migration 015 prévoit un battement de cœur par service de fond ; celui
# de l'enqueue n'existait pas. Un daemon mort restait donc invisible dans le
# bandeau de santé de l'admin.
import contextlib
@contextlib.contextmanager
def get_conn():
yield conn
def health_check():
maybe_run(get_conn, "geocoder-enqueue", [
("database", database_probe(get_conn)),
# L'alimentation tourne au moins toutes les AUTO_INTERVAL_MIN
# minutes : au-delà du double, elle ne passe plus.
("progression", freshness_probe(
get_conn,
"SELECT max(started_at) FROM crawl_run WHERE run_type = 'geocoder_enqueue'",
max(2, (AUTO_INTERVAL_MIN * 2) // 60), "dernière alimentation")),
])
last_auto = time.monotonic() last_auto = time.monotonic()
with conn.cursor() as cur: with conn.cursor() as cur:
@ -287,6 +308,7 @@ def main():
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
health_check()
_sleep_interruptible(POLL_INTERVAL) _sleep_interruptible(POLL_INTERVAL)
continue continue

View file

@ -26,6 +26,7 @@ from datetime import UTC, datetime
import psycopg2 import psycopg2
import requests import requests
from bm_health import database_probe, freshness_probe, maybe_run
# sys.path[0] = src/ quand lancé comme "python src/groq_worker.py" # sys.path[0] = src/ quand lancé comme "python src/groq_worker.py"
from parser import COUNTRY_NAMES from parser import COUNTRY_NAMES
@ -187,6 +188,32 @@ def main():
min_start = time.monotonic() min_start = time.monotonic()
day_start = datetime.now(UTC).date() day_start = datetime.now(UTC).date()
# La migration 015 annonce un battement de cœur pour 'groq-worker', mais
# aucun n'était jamais écrit : sa ligne n'existait pas, et le bandeau de
# santé de l'admin ne pouvait donc rien signaler — y compris quand le
# service était mort.
import contextlib
@contextlib.contextmanager
def get_conn():
yield conn
def health_check():
maybe_run(get_conn, "groq-worker", [
("database", database_probe(get_conn)),
("groq_cle", lambda: (bool(GROQ_API_KEY),
"clé absente" if not GROQ_API_KEY else "clé présente")),
# Le pipeline doit avancer : au-delà de 24 h sans lieu sorti de la
# file LLM alors qu'il en reste, quelque chose bloque.
("progression", freshness_probe(
get_conn,
"SELECT max(updated_at) FROM band_locations "
"WHERE geocode_status IN ('queued','manual') AND geocode_tries_llm > 0",
24, "dernier lieu traité par le LLM")),
])
health_check()
with conn.cursor() as cur: with conn.cursor() as cur:
while not _shutdown: while not _shutdown:
# Reset compteurs si nouvelle minute / nouveau jour # Reset compteurs si nouvelle minute / nouveau jour
@ -224,6 +251,7 @@ def main():
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
print("[groq] rien à traiter, attente 120s") print("[groq] rien à traiter, attente 120s")
health_check()
_sleep_interruptible(120) _sleep_interruptible(120)
continue continue

View file

@ -1,133 +0,0 @@
"""
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)

View file

@ -23,7 +23,7 @@ import time
import psycopg2 import psycopg2
import requests import requests
from health import database_probe, freshness_probe, http_probe, maybe_run from bm_health import database_probe, freshness_probe, http_probe, maybe_run
from parser import build_fallback_queries from parser import build_fallback_queries
GEOAPIFY_API_KEY = os.environ.get("GEOAPIFY_API_KEY", "").strip() GEOAPIFY_API_KEY = os.environ.get("GEOAPIFY_API_KEY", "").strip()

View file

@ -1,3 +1,10 @@
# Le module de sante est partage entre les apps (libs/bm_health.py) : en
# Docker il arrive via PYTHONPATH, ici on l'ajoute au chemin d'import.
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "libs"))
import os import os
import sys import sys
from pathlib import Path from pathlib import Path

View file

@ -12,7 +12,8 @@ services:
crawler: crawler:
build: build:
context: apps/crawler context: .
dockerfile: apps/crawler/Dockerfile
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
FLARESOLVERR_URL: http://flaresolverr:8191 FLARESOLVERR_URL: http://flaresolverr:8191
@ -29,7 +30,8 @@ services:
geocoder-enqueue: geocoder-enqueue:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
@ -45,7 +47,8 @@ services:
geocoder-worker: geocoder-worker:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
@ -64,7 +67,8 @@ services:
groq-worker: groq-worker:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}

View file

@ -1,7 +1,8 @@
services: services:
geocoder-enqueue: geocoder-enqueue:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
@ -17,7 +18,8 @@ services:
geocoder-worker: geocoder-worker:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
@ -36,7 +38,8 @@ services:
groq-worker: groq-worker:
build: build:
context: apps/geocoder context: .
dockerfile: apps/geocoder/Dockerfile
working_dir: /app working_dir: /app
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}

View file

@ -1,5 +1,13 @@
""" """
Contrôle de santé périodique des services de fond. Contrôle de santé périodique des services de fond module PARTAGÉ.
Vit dans libs/ et non dans une app : le crawler et le geocoder en avaient
deux copies rigoureusement identiques, dont une seule était couverte par des
tests. Rien ne signalait une divergence, et une correction appliquée d'un
seul côté serait passée inaperçue.
Embarqué dans les images par les Dockerfile (contexte de build = racine du
dépôt) et rendu importable en local par les conftest.py de chaque app.
Ces services ne sont pas exposés par Traefik : aucune sonde HTTP externe ne peut 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 les atteindre. Sans ce module, un crawler dont FlareSolverr est injoignable ou