Skip to content

Fix plan — s42 run-hang (ADR-0096 implementation + re-run)

Statut : DRAFT r2 (committee plan_review a1521afd REJECTED — 2 blockers adressés) (revue opérateur foldée — deploy blast-radius séquencé WARN→fail-loud). Implémente le RCA (action items A1-A6) + ADR-0096 (active, committee PASSED). Story : CVN-N001-EI-S04 (wp#227) · lié au hub S04.

1. Objectif

Fermer le mode de défaillance (oversubscription threads+processus → throttle cgroup → busy-wait livelock) et débloquer le verdict status-B : (a) implémenter ADR-0096 (caps env-level structural), (b) appliquer à s42 + s43, (c) re-trigger s42 corrigé, (d) backstops (timeout + progress-log) pour qu'un futur hang soit visible et tué.

2. Changements par couche

A. Infra — entrypoint partagé (base Docker image, ADR-0096 reco #2) — load-bearing

Fichier Changement
airflow_docker/cvn_thread_caps.sh (NOUVEAU) Entrypoint wrapper, avant tout import Python : lit $CVN_POD_CPU_LIMIT → exporte OMP_NUM_THREADS/OPENBLAS_NUM_THREADS/MKL_NUM_THREADS/NUMEXPR_NUM_THREADS/LOKY_MAX_CPU_COUNT = floor(limit) + OMP_WAIT_POLICY=passive → puis exec "$@". Fail-loud : si CVN_POD_CPU_LIMIT unset/non-numérique → echo "event=pod_thread_caps_unsafe reason=cpu_limit_undeclared severity=error" + exit 1 (ADR-0096 Inv 2).
airflow_docker/Dockerfile.k8s COPY cvn_thread_caps.sh + ENTRYPOINT ["/cvn_thread_caps.sh"] (wrappe la commande existante).
dags/_common.pySTANDARD_POD PARTAGÉ (pas per-DAG, sinon forgettable = la rot qu'ADR-0096 rejette) Ajouter au pod spec l'env Downward API : CVN_POD_CPU_LIMITresourceFieldRef: {resource: limits.cpu}. Source du cap (spec K8s = vérité). Tous les diag DAGs qui en héritent l'obtiennent d'un coup.

Fractionnaire : limits.cpu arrondit au cœur sup (3.5→4). Lire en millicores si exactitude requise (divisor: 1m → 3500, floor/1000=3) ; sinon assumer limites entières (cas usuel — nos pods = limits.cpu=4). ADR-0096 validation notes.

B. s42 — belt-and-suspenders + config (A1, facteur §5.1)

Fichier Changement
s42_lgb_capacity_ablation.py (_train_lgb_with_override) Passer num_threads = int(os.environ.get("OMP_NUM_THREADS", 1)) dans les params LGB (belt-and-suspenders, ADR-0096 clause 4 ; l'env-level reste le fix primaire). Pas de n_jobs=-1 ailleurs (Inv 5).
dag_diagnostic__s42.py skip_phase_a reste un param explicite par-run — le DÉFAUT N'EST PAS CHANGÉ (blocker committee #1). Le re-trigger warm passe skip_phase_a=true explicitement ; les runs par défaut gardent l'anchor-check → le signal A6 n'est PAS droppé silencieusement. Faire de skip le défaut est gated sur une détection A6 indépendante (cf. A6 ci-dessous), pas livré ici.

C. s43 — héritage + conformité (A3)

Fichier Changement
s43_economic_tradability.py Déjà conforme : group_bootstrap_ci boucle B=2000 en série (pas de n_jobs) — mais le série n'est pas mono-thread (chaque op numpy utilise les 4 threads BLAS cappés). Action : MESURER le walltime série une fois (sur le smoke) → si viable (~minutes), LOCK série explicitement (commentaire « n_jobs interdit ici, série mesuré à Xs, ADR-0096 Inv 5 ») ; si trop lent, parallélisme borné (n_jobs × thread_cap ≤ limit). Ne pas laisser « série ou parallélisé » non-tranché → sinon quelqu'un parallélise « parce que c'est lent » dans 6 mois → re-livelock. Les probes (cold-train) héritent les caps env (rien de spécifique).
dag_diagnostic__s43.py STANDARD_POD : même env Downward API CVN_POD_CPU_LIMIT (via _common.py).
tests/unit/test_s43_*.py Inv I7 (ADR-0096 Inv 3, unit) : asserter que le chemin de fit ne passe pas n_jobs=-1 et que num_threads est sourcé de l'env (≠ None, ≠ hardcodé node cores). Le « ≤ cgroup » = smoke/integration.

D. Backstops (A4, A5) + recos comité

Item Changement
Task timeout (A4) execution_timeout sur discriminate_cell (s42/s43) = ~10-12h (couvre le worst-case légitime incluant le fallback cold ~2h/cellule). Tue un hang sans tuer un run lent-mais-sain.
Progress-log (A5) event=sNN_fit_progress fit=<i>/<N> ... par fit → un hang devient visible en Loki (le dernier event utile était s22a1_hp_resolved, puis rien).
Prometheus panel (reco #4) « fits completed vs CPU burnt » → détecte un slowdown de contention résiduelle.
Lint n_jobs (reco #3) grep-gate CI : aucun n_jobs explicite > 1 dans les diagnostics compute (pas seulement =-1). Complémentaire à LOKY_MAX_CPU_COUNT** : LOKY borne le cas **auto** (n_jobs=-1cpu_count()→ env-borné) ; mais unn_jobs=8` explicite override l'env → le lint le catche. Ensemble ils ferment auto + explicite.
threadpoolctl (reco #1) check threadpool_info() au startup (ou CI) → confirme le BLAS réel (OpenBLAS vs MKL) et que le cap le touche.

E. A6 — détection replay-divergence INDÉPENDANTE (blocker committee #1, gate du défaut skip)

Le signal repro ne doit plus dépendre du FAIL d'ancre Phase A. Mécanisme autonome :

Item Changement
Determinism check Job (périodique OU CI nightly) : s18_step0_replay lancé 2× avec le même seed → asserter f1 bit-identical. Un écart = non-déterminisme (seed non figé / ordre LdP loop / fenêtre données). event=s18_replay_determinism status=PASS\|FAIL.
Canonical-divergence check Comparer le f1 replay au f1 canonique stocké (le expected du verdict s18_step0) hors du chemin run : abs_delta > epsilonevent=s18_replay_divergence severity=warn abs_delta=<>. Indépendant de skip_phase_a.
Gate skip_phase_a=true ne peut devenir défaut que quand ces deux checks tournent (sinon on perd le signal qui a révélé A6). Tant que non livrés → skip reste explicite par-run (§2.B).

3. Séquencement (ordre + dépendances)

Pré-conditions BLOQUANTES (blocker committee #2 — résolues AVANT le deploy dépendant, pas pendant)

Pré-condition Gate
Inventaire pods hors-STANDARD_POD grep -rl Dockerfile.k8s + lister les pod specs n'héritant pas de STANDARD_POD → chacun reçoit le Downward API OU est exclucomplété + commité AVANT le deploy de l'entrypoint (A).
Walltime bootstrap série s43 mesuré sur le smoke → décision lockée (série explicite si viable, sinon parallélisme borné) → AVANT l'impl/run s43 (C).
A6 détection (§2.E) les 2 checks livrés → gate du défaut skip_phase_a=true (sinon skip reste par-run).

PRÉ-COND  inventaire pods hors-STANDARD_POD (complété + commité) ── GATE du deploy A
A0-deploy  Downward API dans STANDARD_POD PARTAGÉ (_common.py)  ── tous les diag DAGs d'un coup
A (entrypoint dans la base image)
   └─> DEPLOY PHASÉ (blast-radius = TOUS les pods) :
        1. mode WARN d'abord (echo severity=warn, PAS d'exit 1)
        2. observer Loki tous-namespaces → repérer les pods qui déclenchent le warn
        3. fixer les stragglers (déclarer le Downward API)
        4. SEULEMENT alors → flip fail-loud (exit 1)   ── « observe avant d'enforcer » (ADR-0093)
B (s42 belt + skip_phase_a)         ── PR avec A
D (task timeout + progress-log)     ── PR avec A/B (backstop avant re-run)
   └─> VÉRIF : re-trigger 1 CELLULE s42 (num_threads pinné, skip_phase_a=true)
        ├─ fits normaux (~3-5 min/fit, progress-log visible) → cause CLOSE (A0/A2)
        └─ re-hang malgré caps → ROUVRIR (RCA A2 : ce n'était pas (que) ça)
A2 (re-trigger full s42 defi_top5)  ── seulement si la vérif 1-cellule passe
C (s43 : mesurer série + lock + test I7) ── avant l'impl s43 complet
A6 (replay divergence)              ── investigation séparée (+ détection propre si skip défaut)
Le deploy de l'entrypoint a un blast-radius « tous les pods » : le phasage WARN→observe→fail-loud le transforme d'un pari (« 100% des pods ont déjà le Downward API » — à prouver, pas supposer) en déploiement observable + réversible. Même prudence que la vérif 1-cellule appliquée à l'infra.

4. Vérification (la preuve finale, RCA A2)

L'attribution OpenMP/BLAS reste inférentielle (pas de thread-dump, pods tués) ; la preuve directe = le fix qui marche. Avant le full run : re-trigger 1 cellule (crypto_group=OPUSDC, skip_phase_a=true). Lire Loki : - ✅ class_balance_applied + s42_* events qui progressent, ~3-5 min/fit → cause confirmée close. - ❌ re-hang (CPU ~3.2 cœurs, 0 progrès) → rouvrir : le busy-wait n'était pas (uniquement) celui qu'on pense (vérifier threadpool_info, chercher un autre vecteur).

5. Tests

  • Unit : I7 (s43, ADR-0096 Inv 3) — caps sourcés env, pas de n_jobs=-1.
  • Smoke/integration : 1-cellule cluster — CPU effectif ≤ cgroup limit (threadpool_info), fits progressent.
  • Entrypoint : test que cvn_thread_caps.sh fail-loud si CVN_POD_CPU_LIMIT unset, et set les 5 caps depuis la limite.

6. Rollback

Revert l'entrypoint cap + l'env Downward API → état pré-incident (threads non-bornés). ⚠️ Ce rollback RÉINTRODUIT la vulnérabilité (retour au livelock) — c'est une mesure d'urgence (si l'entrypoint lui-même casse le démarrage des pods), pas un fallback casual. Aucun impact data/logique (env + check startup). Le re-trigger s42 est read-only (ADR-2).

7. Risques

  • Contention nœud résiduelle (5×4=20>16) : slowdown ~25%, pas livelock (ADR-0096 Consequences). Accepté pour le verdict one-shot ; anti-affinity si industrialisé.
  • BLAS mismatch : si numpy lié OpenBLAS mais on ne cappe que MKL → cap raté. Mitigé par threadpool_info (reco #1) — couvert par les 4 env de toute façon.
  • Image deploy — blast-radius TOUS les pods (le risque #1, RÉSOLU §3) : l'entrypoint fail-loud peut tuer au deploy tout pod sans CVN_POD_CPU_LIMIT. Résolu par le deploy phasé WARN→observe→fail-loud (§3) + Downward API dans STANDARD_POD partagé + inventaire des pods hors-STANDARD_POD. Décision : déclarer le Downward API partout (cohérent avec « unconditional » d'ADR-0096) ; le WARN intermédiaire trouve les oublis avant qu'ils ne cassent.

8. Mapping RCA action items → changements

RCA Couvert par
A1 (env caps) §2.A (entrypoint) + §2.B (belt)
A2 (re-trigger) §3 + §4 (vérif 1-cellule puis full)
A3 (invariant s43) §2.C + ADR-0096 (déjà active) + test I7
A4 (task timeout) §2.D (~10-12h)
A5 (progress-log) §2.D
A6 (replay divergence) §2.E (détection indépendante — gate du défaut skip)