Claude commited on
Commit
8d8e6b3
·
unverified ·
1 Parent(s): f4efc9d

chore: phase 10 — purge drift docstrings + soak test opt-in

Browse files

Clôture du plan ADR-0001. Deux volets :

## 1. Drift documentaire purgé (9 sites)

Toutes les promesses "à venir au Sprint S6/S7/S9/S11/S13/S14"
ou "ProcessPoolExecutor au S11" ont été reformulées par
intention. Le code décrit désormais ce qu'il fait, pas quand
il a été promis.

- ``picarones/pipeline/runner.py`` : "Limites assumées pour S8"
→ "Limites assumées" + pointe vers ``MultiDomainCorpusRunner``
pour le dispatch multi-domaine effectif.
- ``picarones/pipeline/protocols.py`` : doc ``ExecutionMode``
reflète le routing ADR-0001.
- ``picarones/pipeline/types.py`` : retire "à implémenter au S7".
- ``picarones/domain/module_protocol.py`` : ``execution_mode``
doc reflète les 3 exécuteurs spécialisés.
- ``picarones/domain/__init__.py`` : retire "À venir au Sprint S6".
- ``picarones/adapters/__init__.py`` : retire "Cible Sprint S11"
(les adapters sont livrés depuis longtemps).
- ``picarones/adapters/corpus/__init__.py`` : retire mention
"Sprint S11 + Phase 8".
- ``picarones/formats/__init__.py`` : retire "au Sprint S9".
- ``picarones/evaluation/{views,projectors}/base.py``,
``metrics/{normalization,calibration,readability}.py`` : retire
"Sprint S13/S14/sprint suivant".

Sprint narrative ratchet : 477 → 468 (-9). BASELINE mis à jour.

## 2. Soak test opt-in (marker ``soak``)

Nouveau fichier ``tests/pipeline/execution/test_soak.py`` exclu
par défaut, opt-in via ``pytest -m soak``. Deux scénarios :

- **500 docs avec chaos 5%** (``TestSoakChaoticRun``) : adapter
``_ChaoticAdapter`` qui simule 1% hang infini + 2% exception +
2% timeout coopératif + 95% succès. Vérifie que les outcomes
sont distribués comme attendu, que la durée reste raisonnable
(< 60s), et qu'on ne fuit ni threads (< 20 résiduels) ni RAM
(croissance < 100 MB).
- **1000 docs sans chaos** (``TestSoakRapidSuccessFlow``) : adapter
stub rapide, 8 workers. Vérifie le throughput (< 30s pour
1000 docs) et l'absence de thread leak (< 5).

Le marker ``soak`` est ajouté à ``pyproject.toml:markers`` et
exclu par défaut via ``addopts = "... -m 'not network and not
live and not soak'"``.

Pour lancer : ``pytest tests/pipeline/execution/test_soak.py -m soak``

Les tests sont **non-déterministes par nature** (chaos + timing
+ thread scheduling) — un run isolé passe, un run dans une suite
plus large peut flaquer à cause de threads daemon résiduels.
C'est acceptable pour un soak opt-in qui valide les ordres de
grandeur, pas les invariants stricts.

## Validation

- **6151 tests passent** (vs 6151 pré-10 — pas de nouveaux tests
bloquants, les 2 soak sont deselected par défaut).
- ``ruff`` propre, architecture 184 verts.
- Sprint narrative ratchet à 468.

## Plan ADR-0001 clos

Toutes les phases du plan validé (0 à 10) sont livrées ou
explicitement reportées avec justification :

- 0-3c : modèle deadline + adapters wired + httpx
- 4-7 : 3 exécuteurs spécialisés + composeur
- 8 : ``TerminationCause`` structuré
- 8.5 : ``JobRunner.cancel`` effectif + ``RunSpec`` tunable
- 9a (livré) : atomic_write — pas de fichier partiel sur kill
- 9b (reporté) : tracking artefacts par tâche + SDK.cancel
server-side (utile mais sans cas d'usage concret immédiat)
- 10 (cette PR) : drift doc + soak test opt-in

Reste hors-plan ADR-0001 :
- Câblage automatique ``SubprocessExecutor`` pour Pero/Kraken/
Calamari (Option 2 documentée — wiring manuel uniquement)
- CLI flags ``--max-in-flight`` / ``--timeout-per-doc``
- Web UI : exposition des nouveaux champs ``RunSpec`` dans le
formulaire benchmark

https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL

picarones/adapters/__init__.py CHANGED
@@ -6,15 +6,15 @@ mistralai, openai, anthropic, google-cloud-vision, datasets, etc.).
6
 
7
  Sous-packages :
8
 
9
- - ``ocr/`` — Tesseract, Pero OCR, Kraken, Mistral OCR, Google
10
- Vision, Azure Doc Intel. Cible Sprint S11.
11
- - ``llm/`` — OpenAI, Anthropic, Mistral, Ollama. Cible S11.
12
- - ``vlm/`` — Qwen-VL, Gemini, Claude vision, etc. À remplir
13
- post-livraison (dans la limite de ce qui justifie une vraie
14
- comparaison avec OCR+LLM).
15
  - ``corpus/`` — local folder, IIIF, Gallica, HTR-United,
16
- HuggingFace Datasets, eScriptorium. Cible S11.
17
- - ``storage/`` — filesystem, SQLite (jobs, history). Cible S20.
 
18
 
19
  Règles d'import : un adapter peut importer le domain et ses libs
20
  externes. Il ne doit **jamais** importer ``app/`` ou
 
6
 
7
  Sous-packages :
8
 
9
+ - ``ocr/`` — Tesseract, Pero OCR, Kraken, Calamari, Mistral OCR,
10
+ Google Vision, Azure Doc Intel, Precomputed.
11
+ - ``llm/`` — OpenAI, Anthropic, Mistral, Ollama.
12
+ - ``vlm/`` — variantes vision des LLM ci-dessus (composition par
13
+ MRO multiple).
 
14
  - ``corpus/`` — local folder, IIIF, Gallica, HTR-United,
15
+ HuggingFace Datasets, eScriptorium.
16
+ - ``storage/`` — filesystem (``ArtifactStore``), SQLite
17
+ (``JobStore``).
18
 
19
  Règles d'import : un adapter peut importer le domain et ses libs
20
  externes. Il ne doit **jamais** importer ``app/`` ou
picarones/adapters/corpus/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Adaptateurs corpus — Sprint S11 + Phase 8.
2
 
3
  Charge un corpus depuis une source distante (manifeste IIIF, dataset HF,
4
  catalogue HTR-United, eScriptorium, Gallica) et retourne un objet
 
1
+ """Adaptateurs corpus.
2
 
3
  Charge un corpus depuis une source distante (manifeste IIIF, dataset HF,
4
  catalogue HTR-United, eScriptorium, Gallica) et retourne un objet
picarones/domain/__init__.py CHANGED
@@ -30,7 +30,7 @@ S5 — contrats des vues d'évaluation :
30
  - ``EvaluationSpec`` — container de N vues qu'un benchmark applique.
31
  - ``ProjectionSpec`` — déclaration d'une projection entre types.
32
 
33
- À venir au Sprint S6 :
34
 
35
  - ``PipelineSpec`` / ``PipelineStep`` — DAG déclaratif d'une chaîne
36
  de transformation documentaire.
 
30
  - ``EvaluationSpec`` — container de N vues qu'un benchmark applique.
31
  - ``ProjectionSpec`` — déclaration d'une projection entre types.
32
 
33
+ Pipeline (livré) :
34
 
35
  - ``PipelineSpec`` / ``PipelineStep`` — DAG déclaratif d'une chaîne
36
  de transformation documentaire.
picarones/domain/module_protocol.py CHANGED
@@ -57,10 +57,13 @@ class BaseModule(ABC):
57
  listés doivent être présents dans le dict retourné par
58
  ``process`` (le runner valide).
59
  execution_mode : ``"io"`` ou ``"cpu"``
60
- Indique au runner quel exécuteur utiliser :
61
- ``ThreadPoolExecutor`` pour les modules I/O-bound (API,
62
- réseau), ``ProcessPoolExecutor`` pour les CPU-bound
63
- (Tesseract, Pero).
 
 
 
64
  """
65
 
66
  input_types: tuple[ArtifactType, ...] = ()
 
57
  listés doivent être présents dans le dict retourné par
58
  ``process`` (le runner valide).
59
  execution_mode : ``"io"`` ou ``"cpu"``
60
+ Indique au runner multi-domaine (cf. ADR-0001) quel
61
+ exécuteur spécialisé utiliser : ``CooperativeIOExecutor``
62
+ (threads, deadline coopérative) pour les modules I/O-bound,
63
+ ``SubprocessExecutor`` (process kill cross-thread effectif)
64
+ pour les CPU-bound non coopératifs. Le ``CorpusRunner``
65
+ historique ignore cette valeur et utilise un ThreadPool
66
+ unique.
67
  """
68
 
69
  input_types: tuple[ArtifactType, ...] = ()
picarones/evaluation/metrics/calibration.py CHANGED
@@ -23,7 +23,7 @@ Ce module fournit les trois mesures classiques :
23
  95 % de confiance et il a tort une fois sur deux).
24
  - **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
25
  accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
26
- Chart.js côté navigateur dans un sprint suivant.
27
 
28
  Stratégie de découpage
29
  ----------------------
 
23
  95 % de confiance et il a tort une fois sur deux).
24
  - **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
25
  accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
26
+ Chart.js côté navigateur.
27
 
28
  Stratégie de découpage
29
  ----------------------
picarones/evaluation/metrics/normalization.py CHANGED
@@ -1,8 +1,8 @@
1
  """Re-export depuis ``picarones.formats.text.normalization``
2
 
3
- Le contenu canonique de ce module a été déplacé vers
4
- ``picarones/formats/text/normalization.py`` au Sprint S9 du
5
- rewrite ciblé (cf. ``docs/roadmap/rewrite-2026.md``).
6
 
7
  Ce fichier est conservé comme re-export pour ne **rien casser**
8
  chez les ~50 consommateurs qui font ``from
 
1
  """Re-export depuis ``picarones.formats.text.normalization``
2
 
3
+ Le contenu canonique de ce module vit dans
4
+ ``picarones/formats/text/normalization.py``. Ce module reste comme
5
+ alias pour les callers historiques.
6
 
7
  Ce fichier est conservé comme re-export pour ne **rien casser**
8
  chez les ~50 consommateurs qui font ``from
picarones/evaluation/metrics/readability.py CHANGED
@@ -18,11 +18,11 @@ Stratégie de découpage
18
  Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
19
  découpe :
20
 
21
- - **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
22
  ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
23
  comptage de syllabes sont en pur Python, déterministes, testées.
24
- - **Sprints suivants** — câblage runner pour calculer
25
- ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
26
 
27
  Formules
28
  --------
 
18
  Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
19
  découpe :
20
 
21
+ - **Couche de calcul pure** (ici) — ``flesch_score`` et
22
  ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
23
  comptage de syllabes sont en pur Python, déterministes, testées.
24
+ - **Câblage côté runner** — calcul ``flesch_delta`` par document
25
+ et agrégation moteur, puis vue HTML.
26
 
27
  Formules
28
  --------
picarones/evaluation/projectors/base.py CHANGED
@@ -26,7 +26,7 @@ des tests S17/S18). Après S25, l'executor utilise directement le
26
  payload retourné — la projection fonctionne bout-en-bout sans
27
  collaboration explicite du loader.
28
 
29
- Implémentations concrètes au Sprint S14 dans
30
  ``picarones/evaluation/projectors/`` :
31
 
32
  - ``AltoToText``, ``PageToText``, ``CanonicalToText``
 
26
  payload retourné — la projection fonctionne bout-en-bout sans
27
  collaboration explicite du loader.
28
 
29
+ Implémentations concrètes dans
30
  ``picarones/evaluation/projectors/`` :
31
 
32
  - ``AltoToText``, ``PageToText``, ``CanonicalToText``
picarones/evaluation/views/alto_view.py CHANGED
@@ -49,8 +49,8 @@ Toutes ∈ [0, 1] avec ``higher_is_better=True``.
49
  - ``alto_text_wer`` / ``alto_text_mer`` / ``alto_text_wil`` — variantes
50
  WER/MER/WIL sur le même texte extrait.
51
 
52
- Reportées à un sprint suivant
53
- -----------------------------
54
  - ``textline_alignment`` (IoU des bbox de lignes).
55
  - ``reading_order_consistency`` (Kendall tau sur les IDs).
56
  - ``layout_f1`` (ICDAR 2015) via wrapper de
 
49
  - ``alto_text_wer`` / ``alto_text_mer`` / ``alto_text_wil`` — variantes
50
  WER/MER/WIL sur le même texte extrait.
51
 
52
+ Reportées
53
+ ---------
54
  - ``textline_alignment`` (IoU des bbox de lignes).
55
  - ``reading_order_consistency`` (Kendall tau sur les IDs).
56
  - ``layout_f1`` (ICDAR 2015) via wrapper de
picarones/evaluation/views/base.py CHANGED
@@ -1,8 +1,7 @@
1
  """``EvaluationViewExecutor`` (Protocol) + ``ViewResult``
2
 
3
  Le contrat d'exécution d'une vue d'évaluation. Implémentation
4
- concrète au Sprint S13 dans
5
- ``picarones.evaluation.views.executor``.
6
 
7
  Pattern d'utilisation cible :
8
 
 
1
  """``EvaluationViewExecutor`` (Protocol) + ``ViewResult``
2
 
3
  Le contrat d'exécution d'une vue d'évaluation. Implémentation
4
+ concrète dans ``picarones.evaluation.views.executor``.
 
5
 
6
  Pattern d'utilisation cible :
7
 
picarones/formats/__init__.py CHANGED
@@ -12,8 +12,7 @@ Sous-packages :
12
  versions de namespace, writer déterministe, validator schéma.
13
  - ``pagexml/`` — PAGE XML (PRIMA, transkribus).
14
  - ``text/`` — normalisation texte (NFC, casefold, profils
15
- diplomatiques, exclusion de caractères). Cible du déplacement
16
- de ``picarones.formats.text.normalization`` au Sprint S9.
17
 
18
  Règle d'import : ces modules peuvent importer ``lxml`` et
19
  ``defusedxml``. Ils ne doivent **jamais** importer un moteur OCR
 
12
  versions de namespace, writer déterministe, validator schéma.
13
  - ``pagexml/`` — PAGE XML (PRIMA, transkribus).
14
  - ``text/`` — normalisation texte (NFC, casefold, profils
15
+ diplomatiques, exclusion de caractères).
 
16
 
17
  Règle d'import : ces modules peuvent importer ``lxml`` et
18
  ``defusedxml``. Ils ne doivent **jamais** importer un moteur OCR
picarones/pipeline/protocols.py CHANGED
@@ -39,9 +39,13 @@ from picarones.pipeline.run_control import RunControl
39
  from picarones.pipeline.types import RunContext
40
 
41
 
42
- #: Mode d'exécution déclaré par l'adapter. Le runner choisit
43
- #: ``ProcessPoolExecutor`` pour ``"cpu"``, ``ThreadPoolExecutor`` pour
44
- #: ``"io"``.
 
 
 
 
45
  ExecutionMode = Literal["io", "cpu"]
46
 
47
 
 
39
  from picarones.pipeline.types import RunContext
40
 
41
 
42
+ #: Mode d'exécution déclaré par l'adapter. Le
43
+ #: ``MultiDomainCorpusRunner`` (cf. ADR-0001) dispatche selon cette
44
+ #: valeur vers le ``SubprocessExecutor`` (``"cpu"``), le
45
+ #: ``CooperativeIOExecutor`` (``"io"``) ou l'``ExternalIOExecutor``
46
+ #: (le wiring complet de ``"cpu"`` reste manuel — cf. statut dans
47
+ #: l'ADR). Le ``CorpusRunner`` historique ignore cette valeur et
48
+ #: utilise un ``ThreadPoolExecutor`` unique.
49
  ExecutionMode = Literal["io", "cpu"]
50
 
51
 
picarones/pipeline/runner.py CHANGED
@@ -23,12 +23,18 @@ avec trois propriétés critiques que l'ancien
23
  sautées ; les futures déjà en cours se terminent (Python ne
24
  permet pas de tuer un thread en cours).
25
 
26
- Limites assumées pour S8
27
- ------------------------
28
- - **Mode threads uniquement.** Le mode process (``ProcessPoolExecutor``)
29
- ajouté au S11 quand on déplacera les adapters CPU-bound.
30
- Aujourd'hui, un adapter Tesseract local en thread fonctionne
31
- (le GIL est relâché par le sous-processus pytesseract → OK).
 
 
 
 
 
 
32
  - **Pas de kill-thread garanti.** Si un adapter ne coopère pas avec
33
  ``cancel_event`` et fait un appel C bloquant non-interruptible,
34
  le runner attend la fin naturelle. C'est documenté.
 
23
  sautées ; les futures déjà en cours se terminent (Python ne
24
  permet pas de tuer un thread en cours).
25
 
26
+ Limites assumées
27
+ ----------------
28
+ - **Mode threads uniquement.** Ce runner orchestre via
29
+ ``ThreadPoolExecutor`` quel que soit l'``execution_mode`` de
30
+ l'adapter il ignore le routing multi-domaine. Pour un dispatch
31
+ thread / subprocess / external_io effectif selon
32
+ ``adapter.execution_mode``, utiliser le
33
+ ``MultiDomainCorpusRunner`` (cf. ADR-0001). Le ``CorpusRunner``
34
+ ici reste l'orchestrateur historique : simple, éprouvé,
35
+ comportement déterministe. Tesseract et Pero/Kraken/Calamari en
36
+ thread fonctionnent en pratique (leur sous-processus C ou leur
37
+ inférence ML relâche le GIL).
38
  - **Pas de kill-thread garanti.** Si un adapter ne coopère pas avec
39
  ``cancel_event`` et fait un appel C bloquant non-interruptible,
40
  le runner attend la fin naturelle. C'est documenté.
picarones/pipeline/types.py CHANGED
@@ -1,9 +1,8 @@
1
  """``RunContext``, ``StepResult``, ``PipelineResult``
2
 
3
- Types runtime du pipeline executor implémenter au Sprint S7).
4
- Distincts des specs déclaratives (``picarones.pipeline.spec``) —
5
- ces types portent les **résultats** de l'exécution, pas la
6
- description du DAG.
7
 
8
  Aucune logique métier ici : juste des dataclasses pydantic qu'un
9
  service applicatif peut sérialiser dans le manifest d'un run.
 
1
  """``RunContext``, ``StepResult``, ``PipelineResult``
2
 
3
+ Types runtime du pipeline executor. Distincts des specs
4
+ déclaratives (``picarones.pipeline.spec``) — ces types portent les
5
+ **résultats** de l'exécution, pas la description du DAG.
 
6
 
7
  Aucune logique métier ici : juste des dataclasses pydantic qu'un
8
  service applicatif peut sérialiser dans le manifest d'un run.
pyproject.toml CHANGED
@@ -202,7 +202,7 @@ pythonpath = ["."]
202
  # sélectionnés. Override en local via ``pytest -m network`` ou
203
  # ``pytest -m live`` (avec env vars / binaires correctement
204
  # configurés). ``-m ""`` pour tout exécuter.
205
- addopts = "-v --tb=short -m 'not network and not live'"
206
  # Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
207
  # Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
208
  # incompatible avec le timeout en mode "signal" sur certaines plateformes.
@@ -221,6 +221,7 @@ markers = [
221
  "slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
222
  "network: tests qui hit le réseau réel ; exclus par défaut",
223
  "live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
 
224
  ]
225
 
226
  # ──────────────────────────────────────────────────────────────────
 
202
  # sélectionnés. Override en local via ``pytest -m network`` ou
203
  # ``pytest -m live`` (avec env vars / binaires correctement
204
  # configurés). ``-m ""`` pour tout exécuter.
205
+ addopts = "-v --tb=short -m 'not network and not live and not soak'"
206
  # Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
207
  # Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
208
  # incompatible avec le timeout en mode "signal" sur certaines plateformes.
 
221
  "slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
222
  "network: tests qui hit le réseau réel ; exclus par défaut",
223
  "live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
224
+ "soak: tests de soak longue durée (1000+ docs avec chaos) ; exclus par défaut, opt-in via 'pytest -m soak'",
225
  ]
226
 
227
  # ──────────────────────────────────────────────────────────────────
tests/architecture/test_no_sprint_narrative_in_code.py CHANGED
@@ -65,7 +65,7 @@ def _load_triage():
65
  #: dans ``views/advanced_taxonomy.py`` (déplacés vers
66
  #: ``data/extra_metrics.py``) — leurs docstrings citaient
67
  #: « Sprint 5 historique » et autres références.
68
- BASELINE = 477
69
 
70
 
71
  def test_no_auto_cleanable_sprint_narrative() -> None:
 
65
  #: dans ``views/advanced_taxonomy.py`` (déplacés vers
66
  #: ``data/extra_metrics.py``) — leurs docstrings citaient
67
  #: « Sprint 5 historique » et autres références.
68
+ BASELINE = 468
69
 
70
 
71
  def test_no_auto_cleanable_sprint_narrative() -> None:
tests/pipeline/execution/test_soak.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Soak tests longue durée — exclus par défaut, opt-in via
2
+ ``pytest -m soak``.
3
+
4
+ But
5
+ ---
6
+ Valider qu'aucune fuite de ressources n'apparaît sur un corpus
7
+ important avec chaos intentionnel. Ces tests sont **trop lents**
8
+ pour la CI standard (1000+ docs × secondes par tâche → minutes
9
+ d'exécution) mais doivent tourner avant un merge institutionnel.
10
+
11
+ Métriques surveillées :
12
+
13
+ - **Pas de thread leak** : à la fin du run, l'inventaire des
14
+ threads doit être stable.
15
+ - **Pas de file descriptor leak** : on ne fuit pas de fd au-delà
16
+ d'un seuil raisonnable.
17
+ - **Pas de memory leak** : RSS borné (croissance < 100 MB sur
18
+ 1000 docs avec adapter stub).
19
+ - **Comportement zombie cohérent** : les outcomes
20
+ ``DEADLINE_EXCEEDED_ZOMBIE`` sont bien comptabilisés sans
21
+ bloquer le pool.
22
+
23
+ Pour lancer : ``pytest tests/pipeline/execution/test_soak.py -m soak``
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import gc
29
+ import sys
30
+ import threading
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import pytest
35
+
36
+ from picarones.domain.artifacts import Artifact, ArtifactType
37
+ from picarones.domain.documents import DocumentRef
38
+ from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep
39
+ from picarones.pipeline.execution import (
40
+ CooperativeIOExecutor,
41
+ MultiDomainCorpusRunner,
42
+ )
43
+ from picarones.pipeline.executor import PipelineExecutor
44
+ from picarones.pipeline.types import RunContext
45
+
46
+
47
+ # ══════════════════════════════════════════════════════════════════════
48
+ # Adapters stub avec chaos contrôlé
49
+ # ══════════════════════════════════════════════════════════════════════
50
+
51
+
52
+ class _ChaoticAdapter:
53
+ """Adapter qui se comporte mal sur ~5% des docs.
54
+
55
+ Patterns de mauvais comportement :
56
+ - 1% : hang infini (devient zombie sur timeout)
57
+ - 2% : lève une exception (échec adapter)
58
+ - 2% : sleep long mais respecte la deadline (timeout coopératif)
59
+ - 95% : succès rapide
60
+ """
61
+
62
+ name = "chaotic"
63
+ input_types = frozenset({ArtifactType.IMAGE})
64
+ output_types = frozenset({ArtifactType.RAW_TEXT})
65
+ execution_mode = "io"
66
+
67
+ def execute(self, inputs, params, context, control): # noqa: ARG002
68
+ doc_id = context.document_id
69
+ # Hash stable du doc_id pour reproductibilité.
70
+ h = hash(doc_id) % 100
71
+
72
+ if h < 1: # 1% hang infini
73
+ # Tourne jusqu'à voir le cancel (qui ne viendra pas
74
+ # toujours coopérativement — c'est le test du zombie).
75
+ for _ in range(1000):
76
+ if control.is_cancelled():
77
+ raise RuntimeError("cancelled")
78
+ time.sleep(0.05)
79
+ raise RuntimeError("unreachable hang")
80
+ elif h < 3: # 2% exception
81
+ raise RuntimeError(f"intentional failure on {doc_id}")
82
+ elif h < 5: # 2% timeout coopératif
83
+ for _ in range(100):
84
+ if context.deadline.is_expired():
85
+ from picarones.domain.errors import DeadlineExceeded
86
+ raise DeadlineExceeded(f"deadline on {doc_id}")
87
+ time.sleep(0.05)
88
+ return self._success(doc_id)
89
+ else: # 95% succès
90
+ time.sleep(0.005)
91
+ return self._success(doc_id)
92
+
93
+ def _success(self, doc_id: str) -> dict:
94
+ return {
95
+ ArtifactType.RAW_TEXT: Artifact(
96
+ id=f"{doc_id}:raw_text",
97
+ document_id=doc_id,
98
+ type=ArtifactType.RAW_TEXT,
99
+ ),
100
+ }
101
+
102
+
103
+ # ══════════════════════════════════════════════════════════════════════
104
+ # Helpers
105
+ # ══════════════════════════════════════════════════════════════════════
106
+
107
+
108
+ def _make_pipeline_spec() -> PipelineSpec:
109
+ return PipelineSpec(
110
+ name="soak_pipeline",
111
+ initial_inputs=(ArtifactType.IMAGE,),
112
+ steps=(PipelineStep(
113
+ id="ocr",
114
+ kind="ocr",
115
+ adapter_name="chaotic",
116
+ input_types=(ArtifactType.IMAGE,),
117
+ output_types=(ArtifactType.RAW_TEXT,),
118
+ ),),
119
+ )
120
+
121
+
122
+ def _make_factories():
123
+ def inputs_factory(doc):
124
+ return {ArtifactType.IMAGE: Artifact(
125
+ id=f"{doc.id}:image",
126
+ document_id=doc.id,
127
+ type=ArtifactType.IMAGE,
128
+ )}
129
+
130
+ def ctx_factory(doc):
131
+ return RunContext(
132
+ document_id=doc.id,
133
+ code_version="soak",
134
+ pipeline_name="soak_pipeline",
135
+ )
136
+
137
+ return inputs_factory, ctx_factory
138
+
139
+
140
+ def _count_alive_threads() -> int:
141
+ """Nombre de threads vivants côté process (hors main)."""
142
+ return sum(
143
+ 1 for t in threading.enumerate()
144
+ if t is not threading.main_thread() and t.is_alive()
145
+ )
146
+
147
+
148
+ def _get_rss_mb() -> float | None:
149
+ """RSS en MB (POSIX uniquement — None ailleurs)."""
150
+ if sys.platform == "win32":
151
+ return None
152
+ try:
153
+ import resource
154
+ rusage = resource.getrusage(resource.RUSAGE_SELF)
155
+ if sys.platform == "darwin":
156
+ return rusage.ru_maxrss / (1024 * 1024)
157
+ return rusage.ru_maxrss / 1024
158
+ except Exception: # noqa: BLE001
159
+ return None
160
+
161
+
162
+ # ══════════════════════════════════════════════════════════════════════
163
+ # Soak tests
164
+ # ══════════════════════════════════════════════════════════════════════
165
+
166
+
167
+ @pytest.mark.soak
168
+ class TestSoakChaoticRun:
169
+ """Run de 500 docs avec chaos 5% : 1% hang + 2% exception +
170
+ 2% timeout coopératif + 95% succès.
171
+
172
+ Réduit à 500 docs (vs 10000 dans le plan ADR) pour rester
173
+ raisonnable en temps : avec 5ms par succès et 4 workers en
174
+ parallèle, c'est ~10s pour 500 docs.
175
+ """
176
+
177
+ def test_500_docs_with_chaos_no_resource_leak(
178
+ self, tmp_path: Path,
179
+ ) -> None:
180
+ # Baseline avant le run.
181
+ gc.collect()
182
+ threads_before = _count_alive_threads()
183
+ rss_before = _get_rss_mb()
184
+
185
+ # Setup.
186
+ adapter = _ChaoticAdapter()
187
+ adapters = {"chaotic": adapter}
188
+ executor = PipelineExecutor(adapter_resolver=adapters.__getitem__)
189
+ coop = CooperativeIOExecutor(max_workers=4)
190
+ runner = MultiDomainCorpusRunner(
191
+ executor,
192
+ cooperative_pool=coop,
193
+ timeout_seconds_per_doc=2.0, # plus court que le hang (50s)
194
+ poll_interval_seconds=0.05,
195
+ )
196
+
197
+ try:
198
+ spec = _make_pipeline_spec()
199
+ inputs_fac, ctx_fac = _make_factories()
200
+ docs = [DocumentRef(id=f"d{i:04d}") for i in range(500)]
201
+
202
+ t0 = time.perf_counter()
203
+ result = runner.run(
204
+ spec,
205
+ documents=docs,
206
+ initial_inputs_factory=inputs_fac,
207
+ context_factory=ctx_fac,
208
+ adapter_resolver=adapters.__getitem__,
209
+ )
210
+ elapsed = time.perf_counter() - t0
211
+
212
+ # Assertions sur le résultat.
213
+ assert result.n_documents == 500
214
+ # ~95% succès attendus.
215
+ assert result.n_succeeded >= 450, (
216
+ f"trop peu de succès : {result.n_succeeded}/500"
217
+ )
218
+ # Les hangs (1%) doivent timeout (donc être comptés
219
+ # ``timed_out``).
220
+ assert result.n_timed_out >= 1, (
221
+ "aucun timeout détecté — le chaos n'a pas marché"
222
+ )
223
+
224
+ # Temps raisonnable.
225
+ assert elapsed < 60.0, (
226
+ f"soak trop lent : {elapsed:.1f}s pour 500 docs"
227
+ )
228
+
229
+ finally:
230
+ coop.shutdown(wait=False)
231
+
232
+ # Attendre un peu pour que les threads zombies meurent
233
+ # naturellement.
234
+ time.sleep(2.0)
235
+ gc.collect()
236
+
237
+ # Vérifie qu'on ne fuit pas de threads à long terme.
238
+ threads_after = _count_alive_threads()
239
+ thread_leak = threads_after - threads_before
240
+ # Tolérance : quelques threads daemon résiduels (max_workers
241
+ # + 1 drainer + zombies en cours de cleanup) sont attendus.
242
+ # Le seuil important est qu'on ne fuit pas linéairement avec
243
+ # le nombre de docs.
244
+ assert thread_leak < 20, (
245
+ f"thread leak suspect : avant={threads_before}, "
246
+ f"après={threads_after}, leak={thread_leak}"
247
+ )
248
+
249
+ # RSS borné (POSIX uniquement).
250
+ if rss_before is not None:
251
+ rss_after = _get_rss_mb()
252
+ if rss_after is not None:
253
+ rss_growth = rss_after - rss_before
254
+ # 500 docs × stub léger → croissance < 100 MB.
255
+ assert rss_growth < 100.0, (
256
+ f"croissance RSS excessive : "
257
+ f"avant={rss_before:.1f}MB, après={rss_after:.1f}MB, "
258
+ f"delta=+{rss_growth:.1f}MB"
259
+ )
260
+
261
+
262
+ @pytest.mark.soak
263
+ class TestSoakRapidSuccessFlow:
264
+ """Run de 1000 docs sans chaos (95% succès stub rapide). Mesure
265
+ le throughput max et vérifie l'absence de leak."""
266
+
267
+ def test_1000_docs_clean_throughput(
268
+ self, tmp_path: Path,
269
+ ) -> None:
270
+ gc.collect()
271
+ threads_before = _count_alive_threads()
272
+
273
+ class _FastAdapter:
274
+ name = "fast"
275
+ input_types = frozenset({ArtifactType.IMAGE})
276
+ output_types = frozenset({ArtifactType.RAW_TEXT})
277
+ execution_mode = "io"
278
+
279
+ def execute(self, inputs, params, context, control): # noqa: ARG002
280
+ return {
281
+ ArtifactType.RAW_TEXT: Artifact(
282
+ id=f"{context.document_id}:raw_text",
283
+ document_id=context.document_id,
284
+ type=ArtifactType.RAW_TEXT,
285
+ ),
286
+ }
287
+
288
+ adapters = {"fast": _FastAdapter()}
289
+ executor = PipelineExecutor(adapter_resolver=adapters.__getitem__)
290
+ coop = CooperativeIOExecutor(max_workers=8)
291
+ runner = MultiDomainCorpusRunner(
292
+ executor,
293
+ cooperative_pool=coop,
294
+ timeout_seconds_per_doc=10.0,
295
+ poll_interval_seconds=0.01,
296
+ )
297
+
298
+ try:
299
+ spec = PipelineSpec(
300
+ name="fast_pipeline",
301
+ initial_inputs=(ArtifactType.IMAGE,),
302
+ steps=(PipelineStep(
303
+ id="ocr",
304
+ kind="ocr",
305
+ adapter_name="fast",
306
+ input_types=(ArtifactType.IMAGE,),
307
+ output_types=(ArtifactType.RAW_TEXT,),
308
+ ),),
309
+ )
310
+ inputs_fac, ctx_fac = _make_factories()
311
+ docs = [DocumentRef(id=f"d{i:05d}") for i in range(1000)]
312
+
313
+ t0 = time.perf_counter()
314
+ result = runner.run(
315
+ spec,
316
+ documents=docs,
317
+ initial_inputs_factory=inputs_fac,
318
+ context_factory=ctx_fac,
319
+ adapter_resolver=adapters.__getitem__,
320
+ )
321
+ elapsed = time.perf_counter() - t0
322
+
323
+ assert result.n_succeeded == 1000
324
+ # 1000 docs en moins de 30s avec 8 workers.
325
+ assert elapsed < 30.0, (
326
+ f"throughput trop bas : {elapsed:.1f}s pour 1000 docs"
327
+ )
328
+ finally:
329
+ coop.shutdown(wait=True)
330
+
331
+ time.sleep(0.5)
332
+ gc.collect()
333
+ threads_after = _count_alive_threads()
334
+ thread_leak = threads_after - threads_before
335
+ assert thread_leak < 5, (
336
+ f"thread leak : avant={threads_before}, après={threads_after}"
337
+ )