درس 5 — Exploiter

Exécuter, relancer, reprendre

9 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Le vrai test d'un traitement nocturne n'est pas la nuit où tout marche. C'est le matin où l'astreinte relance une exécution sans savoir si la précédente a fini, celui où une tâche est morte à mi-parcours, celui où le fichier d'entrée n'est jamais arrivé. Chacune de ces situations a une réponse attendue : ne rien refaire, ne refaire que le manquant, échouer bruyamment. Le scénario de bout en bout de ce projet les provoque toutes, sur Google Cloud, avec la vraie planification.

Le mécanisme

e2e.py enchaîne quatre exécutions sur la même date et vérifie huit propriétés :

sequenceDiagram
  participant E as "e2e.py"
  participant S as "Cloud Scheduler"
  participant W as "Workflow"
  participant G as "Cloud Storage"
  E->>G: 3 fichiers sous entrant/<veille>/
  E->>S: jobs run (déclenchement forcé)
  S->>W: exécution 1 (argument vide = la veille)
  W-->>E: SUCCEEDED, 29 avis, 6 tranches + notification ok
  E->>W: exécution 2, même date
  W-->>E: deja_traite (termine.json existe)
  E->>G: supprime 1 stats.json + termine.json (panne simulée)
  E->>W: exécution 3, même date
  W-->>E: SUCCEEDED, 1 seule tranche recalculée + notification ok
  E->>W: exécution 4, date sans fichier
  W-->>E: FAILED + notification echec

La « panne simulée » reproduit l'état exact que laisse une tâche tuée après l'écriture de ses résultats mais avant celle de son marqueur, et un workflow interrompu avant sa dernière étape. La preuve que seule cette tranche est refaite : le numéro de génération de chaque objet stats.json dans Cloud Storage. Une génération qui change signifie un objet réécrit.

files/e2e.py (extrait)
    after = stats_objects(date)
    redone = sorted(k for k in after if after[k]["generation"] != before.get(k, {}).get("generation"))
    ...
        "reprise : seule la tranche perdue est refaite": redone == [victim] and json.loads(r3["result"])["avis"] == 29,

La mise en œuvre

cd files
make cloud-up     # ~1 min 30 ; relançable, met à jour job, workflow et planification
make e2e          # ~4 min
make run DATE=2026-10-15   # une exécution isolée, résultat affiché

make e2e repart d'un état vierge pour la date de la veille (il supprime entrant/, sorties/ et rapports/ de cette date avant de déposer les fichiers) : deux passages successifs donnent le même résultat.

Résultat obtenu (2026-10-16)

15:56:17 3 fichiers déposés sous entrant/2026-10-15/
15:56:38 Cloud Scheduler : exécution forcée de lab-batch-nocturne-nuit
15:57:58 exécution 1 (planifiée) : SUCCEEDED en 81 s -> {'avis': 29,
  'avis_par_fichier': {'appels.jsonl': 10, 'email.jsonl': 10, 'formulaire.jsonl': 9},
  'avis_par_theme': {'autre': 4, 'hebergement': 5, 'remboursement': 9, 'retard_transport': 4, 'service_client': 2, 'site_web': 5},
  'date': '2026-10-15', 'graves': 10, 'statut': 'ok', 'tokens_jobs': 4826, 'tokens_synthese': 1538, 'tranches': 6}
15:58:00 synthèse Gemini :
La veille, 10 appels, 10 emails et 9 formulaires ont été traités. Les thèmes abordés sont : remboursement (9),
site web (5), hébergement (5), autre (4), retard transport (4), service client (2).
Les cas graves identifiés sont :
- EMA-001 : Contacter le client pour organiser le remboursement des billets d'avion et éviter une saisine du médiateur.
...
- FOR-006 : Contacter le client en urgence pour lui envoyer ses billets électroniques et s'assurer qu'il peut partir.
15:58:04 exécution 2 (même date) en 3 s : SUCCEEDED {"date":"2026-10-15","statut":"deja_traite"}
15:58:04 panne simulée : sorties/2026-10-15/appels/t2/part-000-of-002.stats.json et termine.json supprimés
15:59:17 exécution 3 (reprise) en 73 s : SUCCEEDED {"avis":29, ... "graves":10, "statut":"ok", "tokens_jobs":4826, "tokens_synthese":1560, "tranches":6}
15:59:17 tranches recalculées : ['sorties/2026-10-15/appels/t2/part-000-of-002.stats.json']
15:59:25 exécution 4 (date sans fichier) en 5 s : FAILED, notification [{'statut': 'echec', 'date': '2000-01-01',
  'data': {'erreur': '"aucun fichier sous gs://lab-batch-nocturne-<projet>/entrant/2000-01-01/"', 'statut': 'echec'}}]
15:59:25 OK   exécution planifiée réussie
15:59:25 OK   29 avis classés, 1 ligne cassée écartée
15:59:25 OK   6 tranches (3 fichiers x 2 tâches)
15:59:25 OK   notification de fin reçue
15:59:25 OK   relance même date : deja_traite
15:59:25 OK   reprise : seule la tranche perdue est refaite
15:59:25 OK   reprise notifiée
15:59:25 OK   date sans fichier : échec notifié

Les journaux des tâches pendant la reprise montrent le coût d'une tranche sautée face à une tranche refaite :

13:58:35  tranche_ignoree  sorties/2026-10-15/appels/t2/part-001-of-002   tache_terminee  ignoree=True   tokens=0    duree_ms=135
13:58:35  tranche_ignoree  sorties/2026-10-15/email/t2/part-001-of-002    tache_terminee  ignoree=True   tokens=0    duree_ms=159
13:58:36  tranche_ignoree  sorties/2026-10-15/email/t2/part-000-of-002    tache_terminee  ignoree=True   tokens=0    duree_ms=142
13:58:38                   (appels, tranche 0 : 1 lot Gemini)            tache_terminee  ignoree=False  tokens=716  duree_ms=1915
13:59:05  tranche_ignoree  sorties/2026-10-15/formulaire/t2/part-000-of-002 tache_terminee ignoree=True  tokens=0    duree_ms=178

Une première version journalisait, pour une tranche sautée, les tokens lus dans son ancien stats.json : les journaux d'une reprise affichaient une consommation qui n'avait pas eu lieu. Le champ ignoree corrige ce mensonge des journaux.

Rapport du matin généré depuis rapport.json (sorties/rapport.html)

Deux défauts que seuls les vrais passages ont montrés

Gemini compte mal. Dans la première version, le workflow passait à Gemini les statistiques brutes des 6 tranches et lui demandait les volumes. La synthèse a écrit « 8 par formulaire » (il y en avait 9), puis, après correction des volumes par fichier, une somme de thèmes égale à 28 pour 29 avis. Le modèle additionne mal des compteurs répartis dans du JSON. Correction : les totaux par fichier et par thème sont calculés par des boucles du workflow, passés au modèle avec la consigne « recopie-les, ne les recalcule jamais », et écrits tels quels dans le résumé et la notification. Le modèle rédige ; le code compte.

La gravité ne triait rien. Avec la première définition, 15 avis sur 29 étaient classés graves. Avec la définition qui nomme les trois niveaux (leçon 2), 10 sur 29, et ce sont ceux qu'un responsable qualité traiterait en premier : double débit, avoir non versé, enfants séparés de leurs parents, personne à mobilité réduite abandonnée, billets non reçus à cinq jours du départ. Dix sur vingt-neuf reste élevé pour un échantillon volontairement chargé en problèmes ; sur des données réelles, mesurez ce taux chaque semaine.

Ce qui casse en production

  • Une relance pendant une exécution active. L'astreinte relance à 7 h une exécution qui tourne encore (un job lent) : les deux passent le contrôle termine.json. Les tâches sont idempotentes mais Gemini est appelé deux fois pour les tranches en cours. Parade : gcloud workflows executions list lab-batch-nocturne --filter=state=ACTIVE avant toute relance, écrit dans la procédure.
  • Une reprise qui coûte presque autant qu'une nuit. Même quand une seule tranche manque, le workflow relance les trois jobs : 73 s ici, dont l'essentiel en démarrage de conteneurs qui constatent leur marqueur en 150 ms environ et s'arrêtent. Pour 3 fichiers, c'est acceptable ; pour 300, vérifiez les marqueurs dans le workflow avant de lancer chaque job.
  • Délai entre deux jobs. Le connecteur interroge l'opération du job à intervalles, ce qui ajoute plusieurs secondes entre la fin d'un job et le début du suivant (jusqu'à 18 s observées). Sans importance la nuit ; à régler par connector_params si la durée totale devient critique.
  • Notification d'échec qui n'arrive jamais. Si l'erreur survient dans deja_traite (bucket supprimé, droit retiré), elle est levée avant le bloc try qui notifie : l'exécution échoue sans message. Parade : une alerte sur les exécutions FAILED du workflow, en plus de la notification.
  • Synthèse qui dérive. Un changement de modèle ou de consigne peut réintroduire des chiffres faux sans aucune erreur. Parade : comparer dans l'e2e les chiffres de la synthèse à avis_par_fichier (une expression régulière suffit), et faire échouer le test en cas d'écart.

La règle de décision

Testez la relance et la reprise, pas seulement le passage nominal : une exécution sur une date déjà traitée doit être un non-événement, une exécution après panne ne doit refaire que le manquant, une exécution sans entrée doit échouer et prévenir. Laissez au modèle la rédaction et le classement, jamais le calcul de chiffres que le code connaît déjà.

Pratique

À 7 h 50, le rapport manque. La liste des exécutions montre celle de 03:30 en FAILED à 03:41, erreur 429 répétée sur la synthèse Gemini, et les 6 stats.json présents. Que faites-vous ?

Un collègue propose de laisser Gemini calculer aussi le nombre de cas graves dans la synthèse, « puisqu'il a les données ». Que répondez-vous, sur la base des essais de ce projet ?

Exercice. Ajoutez à e2e.py une vérification qui fait échouer le test si la synthèse contient un nombre d'avis par canal différent de avis_par_fichier. Écrivez la fonction.

Voir la solution
import re

CANAUX = {"appels.jsonl": r"appels?", "email.jsonl": r"e-?mails?", "formulaire.jsonl": r"formulaires?"}


def volumes_coherents(synthese: str, par_fichier: dict[str, int]) -> bool:
    for fichier, motif in CANAUX.items():
        m = re.search(rf"(\d+)\s+(?:par\s+)?{motif}", synthese, re.IGNORECASE)
        if not m or int(m.group(1)) != par_fichier.get(fichier, 0):
            return False
    return True

Dans main(), après l'exécution 1 : "synthèse cohérente avec les volumes": volumes_coherents(rapport["synthese"], r1["avis_par_fichier"]). La vérification est volontairement stricte : si le modèle change de tournure (« dix appels » en toutes lettres), le test échoue, et c'est à vous de décider si la consigne doit imposer les chiffres.

  • Je teste la relance d'une date déjà traitée, la reprise après panne et l'absence d'entrée.
  • Je prouve qu'une reprise ne refait que le manquant (générations d'objets, journaux des tâches).
  • Je laisse au code les calculs et au modèle la rédaction.
  • J'ai une procédure de relance qui vérifie d'abord les exécutions actives.
التالي