Spaces:
Sleeping
feat(pipeline): RunControl + RunContext.deadline + DeadlineExceeded
Browse filesPréparation du terrain pour la migration adapter atomique (cf.
ADR-0001). N'introduit pas de breaking change — la signature
``execute(inputs, params, context)`` reste inchangée à ce stade.
Les nouveaux types coexistent sans être encore branchés.
## ``Deadline`` Pydantic-compat
Ajout de ``__get_pydantic_core_schema__`` pour utiliser ``Deadline``
comme type de champ Pydantic. Validation accepte ``Deadline``
direct ou dict ``{"remaining_seconds": float | None}`` (entrée
JSON / IPC). Sérialisation passe par ``to_dict()``.
L'import de ``pydantic_core`` dans la couche ``domain`` est
ajouté à la whitelist ``EXTERNAL_ALLOWED`` : c'est le backend
Rust de ``pydantic`` et son API officielle pour types custom,
sémantiquement indissociable.
## ``RunContext.deadline``
Nouveau champ Pydantic ``deadline: Deadline = Field(default_factory=
Deadline.infinite)``. Default ``infinite`` → les ~31 sites de
construction de ``RunContext`` dans les tests existants continuent
à fonctionner sans modification.
Round-trip ``model_dump_json`` / ``model_validate_json`` préserve
la sémantique cross-process : l'overhead de dispatch n'est pas
facturé au receveur (test
``test_json_roundtrip_preserves_handoff_semantics``).
## ``RunControl`` (nouveau module)
État runtime mutable d'un step d'exécution, séparé de ``RunContext``
parce qu'il porte du non-sérialisable :
- ``cancel_event`` : ``threading.Event`` partagé caller ↔ adapter.
- ``cancel_handles`` : fonctions enregistrées par l'adapter pour
permettre au runner de kill cross-thread (ex : fermer un client
HTTP, qui fait lever l'exception dans le thread bloqué).
Thread-safe : enregistrement et déclenchement concurrents validés
par stress test (20 threads). Idempotent : double ``trigger_cancel``
= no-op. Un handle enregistré après ``trigger_cancel`` est appelé
immédiatement (l'adapter ne peut pas s'enregistrer et continuer
comme si de rien n'était). Un handle qui lève n'empêche pas les
autres d'être appelés (log warning explicite, pas d'absorption
silencieuse).
## ``DeadlineExceeded`` (nouvelle exception)
Sous-classe transverse d'``AdapterStepError``. Permet à un
adapter de signaler explicitement l'expiration de la deadline
propagée, distincte d'un échec fonctionnel arbitraire. Ne
descend pas d'``OCRAdapterError`` / ``LLMAdapterError`` /
``VLMAdapterError`` — c'est une cause de terminaison transverse,
pas un échec de domaine.
## Validation
- 92 nouveaux tests verts (50 Deadline + 14 RunControl +
15 RunContext.deadline + 13 error_hierarchy).
- **1147 tests non-régression** verts : pipeline (183), adapters
(523), app/services (71), integration (370).
- mypy strict sur domain/ passe.
- Sprint narrative ratchet à 479 (baseline).
- Lint ruff propre.
Imports canoniques :
- ``from picarones.domain import Deadline``
- ``from picarones.domain.errors import DeadlineExceeded``
- ``from picarones.pipeline.run_control import RunControl, RunCancelledError``
https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL
- picarones/domain/deadline.py +44 -0
- picarones/domain/errors.py +38 -0
- picarones/pipeline/run_control.py +235 -0
- picarones/pipeline/types.py +19 -7
- tests/architecture/test_layer_dependencies.py +10 -1
- tests/domain/test_error_hierarchy.py +39 -1
- tests/pipeline/test_run_context_deadline.py +233 -0
- tests/pipeline/test_run_control.py +230 -0
|
@@ -83,6 +83,9 @@ from __future__ import annotations
|
|
| 83 |
import time
|
| 84 |
from typing import Any
|
| 85 |
|
|
|
|
|
|
|
|
|
|
| 86 |
from picarones.domain.errors import PicaronesError
|
| 87 |
|
| 88 |
|
|
@@ -316,5 +319,46 @@ class Deadline:
|
|
| 316 |
return "Deadline(expired)"
|
| 317 |
return f"Deadline(remaining={remaining:.3f}s)"
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
__all__ = ["Deadline"]
|
|
|
|
| 83 |
import time
|
| 84 |
from typing import Any
|
| 85 |
|
| 86 |
+
from pydantic import GetCoreSchemaHandler
|
| 87 |
+
from pydantic_core import core_schema
|
| 88 |
+
|
| 89 |
from picarones.domain.errors import PicaronesError
|
| 90 |
|
| 91 |
|
|
|
|
| 319 |
return "Deadline(expired)"
|
| 320 |
return f"Deadline(remaining={remaining:.3f}s)"
|
| 321 |
|
| 322 |
+
# ──────────────────────────────────────────────────────────────────
|
| 323 |
+
# Compat Pydantic (utilisable comme type de champ dans un BaseModel)
|
| 324 |
+
# ──────────────────────────────────────────────────────────────────
|
| 325 |
+
|
| 326 |
+
@classmethod
|
| 327 |
+
def __get_pydantic_core_schema__(
|
| 328 |
+
cls,
|
| 329 |
+
source_type: Any, # noqa: ARG003
|
| 330 |
+
handler: GetCoreSchemaHandler, # noqa: ARG003
|
| 331 |
+
) -> core_schema.CoreSchema:
|
| 332 |
+
"""Permet à Pydantic de valider/sérialiser un ``Deadline``.
|
| 333 |
+
|
| 334 |
+
Validation accepte deux formes :
|
| 335 |
+
|
| 336 |
+
- une instance ``Deadline`` directe (pass-through, runtime
|
| 337 |
+
typique) ;
|
| 338 |
+
- un dict ``{"remaining_seconds": float | None}`` (entrée
|
| 339 |
+
JSON / IPC — auto-reconstitué relatif au monotonic du
|
| 340 |
+
process receveur via ``from_dict``).
|
| 341 |
+
|
| 342 |
+
Sérialisation : appelle ``to_dict()`` (forme stable et
|
| 343 |
+
transposable).
|
| 344 |
+
"""
|
| 345 |
+
|
| 346 |
+
def _validate(value: Any) -> "Deadline":
|
| 347 |
+
if isinstance(value, cls):
|
| 348 |
+
return value
|
| 349 |
+
if isinstance(value, dict):
|
| 350 |
+
return cls.from_dict(value)
|
| 351 |
+
raise ValueError(
|
| 352 |
+
f"Deadline : impossible de valider une valeur de type "
|
| 353 |
+
f"{type(value).__name__} (attendu Deadline ou dict).",
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
return core_schema.no_info_plain_validator_function(
|
| 357 |
+
_validate,
|
| 358 |
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
| 359 |
+
lambda d: d.to_dict(),
|
| 360 |
+
),
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
|
| 364 |
__all__ = ["Deadline"]
|
|
@@ -71,10 +71,48 @@ class AdapterStepError(PicaronesError):
|
|
| 71 |
"""
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
__all__ = [
|
| 75 |
"PicaronesError",
|
| 76 |
"ArtifactValidationError",
|
| 77 |
"ProjectionError",
|
| 78 |
"CorpusSpecError",
|
| 79 |
"AdapterStepError",
|
|
|
|
| 80 |
]
|
|
|
|
| 71 |
"""
|
| 72 |
|
| 73 |
|
| 74 |
+
class DeadlineExceeded(AdapterStepError):
|
| 75 |
+
"""Un adapter a détecté l'expiration de la ``Deadline`` reçue
|
| 76 |
+
via le ``RunContext``.
|
| 77 |
+
|
| 78 |
+
Levée par un adapter qui respecte coopérativement la deadline
|
| 79 |
+
propagée (cf. [ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)).
|
| 80 |
+
Distincte des autres ``AdapterStepError`` pour permettre au
|
| 81 |
+
runner de catégoriser la cause de terminaison comme
|
| 82 |
+
``DEADLINE_EXCEEDED_COOPERATIVE`` plutôt que comme un échec
|
| 83 |
+
fonctionnel — c'est l'information critique pour l'audit
|
| 84 |
+
institutionnel.
|
| 85 |
+
|
| 86 |
+
Sémantique
|
| 87 |
+
----------
|
| 88 |
+
|
| 89 |
+
Un adapter doit lever ``DeadlineExceeded`` quand :
|
| 90 |
+
|
| 91 |
+
- Sa boucle interne (multi-step, multi-page) détecte
|
| 92 |
+
``context.deadline.is_expired()`` avant d'avoir fini.
|
| 93 |
+
- Son SDK lève une exception type ``TimeoutError`` /
|
| 94 |
+
``httpx.TimeoutException`` (etc.) parce qu'on lui a passé
|
| 95 |
+
``context.deadline.as_sdk_timeout()`` qui s'est déclenché.
|
| 96 |
+
→ l'adapter wrap et re-lève en ``DeadlineExceeded``.
|
| 97 |
+
|
| 98 |
+
Un adapter ne doit PAS lever ``DeadlineExceeded`` pour :
|
| 99 |
+
|
| 100 |
+
- Un timeout HTTP interne au SDK non lié à la deadline
|
| 101 |
+
(ex : timeout TCP par défaut du SDK) → ``AdapterStepError``
|
| 102 |
+
standard.
|
| 103 |
+
- Une expiration côté serveur (ex : 504 Gateway Timeout) →
|
| 104 |
+
``AdapterStepError`` standard avec message explicite.
|
| 105 |
+
|
| 106 |
+
Le runner traduit cette distinction en cause de terminaison
|
| 107 |
+
structurée dans le résultat — ne pas la perdre.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
|
| 111 |
__all__ = [
|
| 112 |
"PicaronesError",
|
| 113 |
"ArtifactValidationError",
|
| 114 |
"ProjectionError",
|
| 115 |
"CorpusSpecError",
|
| 116 |
"AdapterStepError",
|
| 117 |
+
"DeadlineExceeded",
|
| 118 |
]
|
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``RunControl`` — état runtime mutable d'un step d'exécution.
|
| 2 |
+
|
| 3 |
+
Compagnon de ``RunContext`` (cf. ``pipeline/types.py``), porte le
|
| 4 |
+
côté **non sérialisable** d'un step :
|
| 5 |
+
|
| 6 |
+
- ``cancel_event`` : signal de cancellation coopératif (le caller
|
| 7 |
+
set l'event ; l'adapter le check entre opérations bloquantes).
|
| 8 |
+
- ``cancel_handles`` : fonctions enregistrées par l'adapter pour
|
| 9 |
+
permettre au runner de tuer cross-thread l'opération en cours
|
| 10 |
+
(ex : fermer un client HTTP, ce qui fait lever l'exception dans
|
| 11 |
+
le thread bloqué sur la requête).
|
| 12 |
+
|
| 13 |
+
Pourquoi séparé de ``RunContext`` ?
|
| 14 |
+
-----------------------------------
|
| 15 |
+
|
| 16 |
+
``RunContext`` est inclus dans le ``RunManifest`` d'audit ; il doit
|
| 17 |
+
rester sérialisable JSON (Pydantic ``model_dump_json()`` round-trip).
|
| 18 |
+
``threading.Event`` et les ``Callable`` enregistrés par les adapters
|
| 19 |
+
ne sont pas sérialisables et ne survivent pas à un transfert
|
| 20 |
+
cross-process.
|
| 21 |
+
|
| 22 |
+
Décision architecturale validée : ``RunControl`` est un paramètre
|
| 23 |
+
supplémentaire à ``execute()``, **pas** un champ de ``RunContext``.
|
| 24 |
+
Cf. [ADR-0001 décision #13](../../docs/explanation/adr/0001-multi-domain-execution.md).
|
| 25 |
+
|
| 26 |
+
Thread-safety
|
| 27 |
+
-------------
|
| 28 |
+
|
| 29 |
+
Les opérations sur ``cancel_handles`` sont protégées par un lock
|
| 30 |
+
interne — un adapter peut enregistrer son handle depuis un thread
|
| 31 |
+
pendant que le runner appelle ``trigger_cancel()`` depuis un autre.
|
| 32 |
+
|
| 33 |
+
L'``cancel_event`` est un ``threading.Event`` standard, thread-safe
|
| 34 |
+
par construction.
|
| 35 |
+
|
| 36 |
+
Limites assumées
|
| 37 |
+
----------------
|
| 38 |
+
|
| 39 |
+
- ``RunControl`` ne survit pas à un transfert cross-process. Pour
|
| 40 |
+
les adapters exécutés dans un subprocess, le runner construit
|
| 41 |
+
un ``RunControl`` local au subprocess à partir d'un protocole
|
| 42 |
+
IPC dédié (cf. design du ``SubprocessExecutor`` qui sera livré).
|
| 43 |
+
- Pas de hiérarchie parent/enfant pour l'instant — un seul niveau
|
| 44 |
+
de RunControl par doc. Une éventuelle composition (un step
|
| 45 |
+
hérite d'un RunControl plus restrictif) viendra si un besoin
|
| 46 |
+
concret apparaît.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import threading
|
| 52 |
+
from typing import Callable
|
| 53 |
+
|
| 54 |
+
from picarones.domain.errors import PicaronesError
|
| 55 |
+
|
| 56 |
+
#: Type d'un handle de cancellation enregistré par un adapter.
|
| 57 |
+
#: Sans argument, sans valeur de retour. Idempotent (le runner peut
|
| 58 |
+
#: appeler tous les handles enregistrés sans craindre une double
|
| 59 |
+
#: exécution).
|
| 60 |
+
CancelHandle = Callable[[], None]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class RunControl:
|
| 64 |
+
"""État runtime mutable d'un step en cours d'exécution.
|
| 65 |
+
|
| 66 |
+
Construit par le runner pour chaque ``execute()``, passé à
|
| 67 |
+
l'adapter comme paramètre additionnel de la signature
|
| 68 |
+
``execute(inputs, params, context, control)``
|
| 69 |
+
(cf. ADR-0001).
|
| 70 |
+
|
| 71 |
+
Le ``RunControl`` n'est pas immuable : l'adapter le mute en
|
| 72 |
+
appelant ``register_cancel_handle``. L'immutabilité serait
|
| 73 |
+
contre-productive ici (l'adapter doit pouvoir s'auto-enregistrer
|
| 74 |
+
pour permettre au runner de le tuer).
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
def __init__(
|
| 78 |
+
self,
|
| 79 |
+
cancel_event: threading.Event | None = None,
|
| 80 |
+
) -> None:
|
| 81 |
+
"""Construit un ``RunControl``.
|
| 82 |
+
|
| 83 |
+
Parameters
|
| 84 |
+
----------
|
| 85 |
+
cancel_event:
|
| 86 |
+
``threading.Event`` partagé avec le caller pour signaler
|
| 87 |
+
une cancellation. Si ``None``, un event local est créé
|
| 88 |
+
(utile en tests ou pour les usages où il n'y a pas de
|
| 89 |
+
caller à signaler).
|
| 90 |
+
"""
|
| 91 |
+
self._cancel_event = (
|
| 92 |
+
cancel_event if cancel_event is not None else threading.Event()
|
| 93 |
+
)
|
| 94 |
+
self._cancel_handles: list[CancelHandle] = []
|
| 95 |
+
self._handles_lock = threading.Lock()
|
| 96 |
+
self._cancel_triggered = False
|
| 97 |
+
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────
|
| 99 |
+
# Cancellation event (coopératif)
|
| 100 |
+
# ──────────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def cancel_event(self) -> threading.Event:
|
| 104 |
+
"""L'``Event`` partagé ; l'adapter peut le passer à un
|
| 105 |
+
``wait()`` ou le check directement via ``is_set()``."""
|
| 106 |
+
return self._cancel_event
|
| 107 |
+
|
| 108 |
+
def is_cancelled(self) -> bool:
|
| 109 |
+
"""Raccourci pour ``self.cancel_event.is_set()``."""
|
| 110 |
+
return self._cancel_event.is_set()
|
| 111 |
+
|
| 112 |
+
def raise_if_cancelled(self) -> None:
|
| 113 |
+
"""Lève ``RunCancelledError`` si la cancellation a été
|
| 114 |
+
signalée. L'adapter peut appeler ça entre opérations
|
| 115 |
+
bloquantes pour cooperate proprement."""
|
| 116 |
+
if self._cancel_event.is_set():
|
| 117 |
+
raise RunCancelledError(
|
| 118 |
+
"Run cancelled by caller (cancel_event signalled).",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# ────────────────────────────────────────────────────────��─────────
|
| 122 |
+
# Handles de cancellation cross-thread (kill réseau, kill subprocess)
|
| 123 |
+
# ──────────────────────────────────────────────────────────────────
|
| 124 |
+
|
| 125 |
+
def register_cancel_handle(self, handle: CancelHandle) -> None:
|
| 126 |
+
"""Enregistre un handle de cancellation cross-thread.
|
| 127 |
+
|
| 128 |
+
L'adapter appelle cette méthode au début de son
|
| 129 |
+
``execute()`` pour exposer un mécanisme de kill au runner.
|
| 130 |
+
Exemples :
|
| 131 |
+
|
| 132 |
+
- HTTP : ``control.register_cancel_handle(my_httpx_client.close)``
|
| 133 |
+
— fermer le client cross-thread fait lever l'exception
|
| 134 |
+
dans le thread bloqué sur la requête.
|
| 135 |
+
- Subprocess : ``control.register_cancel_handle(my_proc.terminate)``
|
| 136 |
+
— termine le subprocess si la deadline expire.
|
| 137 |
+
|
| 138 |
+
Plusieurs handles peuvent être enregistrés. Tous seront
|
| 139 |
+
appelés (séquentiellement, dans l'ordre d'enregistrement)
|
| 140 |
+
lors de ``trigger_cancel()``.
|
| 141 |
+
|
| 142 |
+
Si la cancellation a déjà été déclenchée, le handle est
|
| 143 |
+
appelé immédiatement — l'adapter ne peut pas s'enregistrer
|
| 144 |
+
et continuer comme si de rien n'était.
|
| 145 |
+
"""
|
| 146 |
+
if not callable(handle):
|
| 147 |
+
raise PicaronesError(
|
| 148 |
+
f"RunControl.register_cancel_handle : handle doit être "
|
| 149 |
+
f"callable, reçu {type(handle).__name__}.",
|
| 150 |
+
)
|
| 151 |
+
with self._handles_lock:
|
| 152 |
+
if self._cancel_triggered:
|
| 153 |
+
# Cancellation déjà déclenchée — appeler le handle
|
| 154 |
+
# immédiatement plutôt que de le stocker pour rien.
|
| 155 |
+
# Hors du lock pour ne pas hold during user code.
|
| 156 |
+
triggered = True
|
| 157 |
+
else:
|
| 158 |
+
self._cancel_handles.append(handle)
|
| 159 |
+
triggered = False
|
| 160 |
+
if triggered:
|
| 161 |
+
_safely_invoke(handle)
|
| 162 |
+
|
| 163 |
+
def trigger_cancel(self) -> None:
|
| 164 |
+
"""Appelle tous les handles enregistrés et set l'``cancel_event``.
|
| 165 |
+
|
| 166 |
+
Appelée par le runner quand une deadline est dépassée ou
|
| 167 |
+
qu'un cancellation utilisateur arrive. Idempotente — un
|
| 168 |
+
second appel est un no-op (les handles ont déjà été
|
| 169 |
+
consommés).
|
| 170 |
+
"""
|
| 171 |
+
with self._handles_lock:
|
| 172 |
+
if self._cancel_triggered:
|
| 173 |
+
return
|
| 174 |
+
self._cancel_triggered = True
|
| 175 |
+
handles_to_call = list(self._cancel_handles)
|
| 176 |
+
self._cancel_handles.clear()
|
| 177 |
+
# Set l'event *avant* d'appeler les handles : si un handle
|
| 178 |
+
# check ``is_cancelled()`` en cascade, il verra True.
|
| 179 |
+
self._cancel_event.set()
|
| 180 |
+
for handle in handles_to_call:
|
| 181 |
+
_safely_invoke(handle)
|
| 182 |
+
|
| 183 |
+
@property
|
| 184 |
+
def cancel_triggered(self) -> bool:
|
| 185 |
+
"""``True`` si ``trigger_cancel()`` a déjà été appelé.
|
| 186 |
+
|
| 187 |
+
Différent de ``is_cancelled()`` : l'``cancel_event`` peut
|
| 188 |
+
être set par le caller via ``cancel_event.set()`` direct,
|
| 189 |
+
sans que les handles aient été appelés. Cette propriété
|
| 190 |
+
traque spécifiquement l'invocation de ``trigger_cancel``.
|
| 191 |
+
"""
|
| 192 |
+
return self._cancel_triggered
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class RunCancelledError(PicaronesError):
|
| 196 |
+
"""Levée par ``RunControl.raise_if_cancelled()`` quand la
|
| 197 |
+
cancellation a été signalée.
|
| 198 |
+
|
| 199 |
+
Distincte de ``DeadlineExceeded`` : la cancellation est un signal
|
| 200 |
+
explicite du caller (utilisateur, deadline globale du run), pas
|
| 201 |
+
une expiration de la deadline du doc en cours. Le runner
|
| 202 |
+
catégorise en ``USER_CANCELLED_MID_EXECUTION`` dans le
|
| 203 |
+
``TerminationLedger`` (cf. ADR-0001).
|
| 204 |
+
"""
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _safely_invoke(handle: CancelHandle) -> None:
|
| 208 |
+
"""Appelle un handle de cancellation en absorbant les exceptions.
|
| 209 |
+
|
| 210 |
+
Si un handle lève (ex : socket déjà fermé), on log mais on
|
| 211 |
+
continue à appeler les autres handles — un handle défaillant
|
| 212 |
+
ne doit pas bloquer la cancellation des autres ressources.
|
| 213 |
+
|
| 214 |
+
Le log doit être suffisamment explicite pour qu'un audit
|
| 215 |
+
institutionnel puisse retrouver la trace ; on n'absorbe pas
|
| 216 |
+
silencieusement.
|
| 217 |
+
"""
|
| 218 |
+
import logging
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
handle()
|
| 222 |
+
except Exception as exc: # noqa: BLE001
|
| 223 |
+
logging.getLogger(__name__).warning(
|
| 224 |
+
"[run_control] cancel handle %r a levé : %s — ignoré, "
|
| 225 |
+
"appel des handles suivants.",
|
| 226 |
+
getattr(handle, "__name__", repr(handle)),
|
| 227 |
+
exc,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
__all__ = [
|
| 232 |
+
"CancelHandle",
|
| 233 |
+
"RunCancelledError",
|
| 234 |
+
"RunControl",
|
| 235 |
+
]
|
|
@@ -16,13 +16,14 @@ from typing import Any
|
|
| 16 |
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
|
| 18 |
from picarones.domain.artifacts import Artifact
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
class RunContext(BaseModel):
|
| 22 |
"""Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
|
| 23 |
|
| 24 |
-
Le caller (typiquement ``app/services/benchmark_service``
|
| 25 |
-
|
| 26 |
l'executor pour chaque étape.
|
| 27 |
|
| 28 |
Attributs
|
|
@@ -41,11 +42,21 @@ class RunContext(BaseModel):
|
|
| 41 |
URI/chemin du workspace dans lequel l'adapter peut écrire
|
| 42 |
ses artefacts intermédiaires. ``None`` autorisé pour les
|
| 43 |
adapters qui n'écrivent rien sur disque (mode in-memory).
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
"""
|
| 50 |
|
| 51 |
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
@@ -54,6 +65,7 @@ class RunContext(BaseModel):
|
|
| 54 |
code_version: str = Field(min_length=1, max_length=128)
|
| 55 |
pipeline_name: str = Field(min_length=1, max_length=128)
|
| 56 |
workspace_uri: str | None = Field(default=None, max_length=2048)
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
class StepResult(BaseModel):
|
|
|
|
| 16 |
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
|
| 18 |
from picarones.domain.artifacts import Artifact
|
| 19 |
+
from picarones.domain.deadline import Deadline
|
| 20 |
|
| 21 |
|
| 22 |
class RunContext(BaseModel):
|
| 23 |
"""Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
|
| 24 |
|
| 25 |
+
Le caller (typiquement ``app/services/benchmark_service``)
|
| 26 |
+
construit un ``RunContext`` par document et le passe à
|
| 27 |
l'executor pour chaque étape.
|
| 28 |
|
| 29 |
Attributs
|
|
|
|
| 42 |
URI/chemin du workspace dans lequel l'adapter peut écrire
|
| 43 |
ses artefacts intermédiaires. ``None`` autorisé pour les
|
| 44 |
adapters qui n'écrivent rien sur disque (mode in-memory).
|
| 45 |
+
deadline:
|
| 46 |
+
``Deadline`` propagée à l'adapter. L'adapter doit la
|
| 47 |
+
passer à son SDK via ``deadline.as_sdk_timeout()`` et la
|
| 48 |
+
check via ``deadline.is_expired()`` entre opérations
|
| 49 |
+
bloquantes (cf.
|
| 50 |
+
[ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)).
|
| 51 |
+
Default : ``Deadline.infinite()`` — pas de contrainte
|
| 52 |
+
temporelle. Le runner construit une deadline finie à
|
| 53 |
+
partir de ``timeout_seconds_per_doc`` du ``RunSpec``.
|
| 54 |
+
|
| 55 |
+
Le ``RunContext`` reste un type valeur **sérialisable** pour
|
| 56 |
+
pouvoir être inclus dans le ``RunManifest`` d'audit. Le côté
|
| 57 |
+
runtime mutable (cancel_event, cancel_handles réseau,
|
| 58 |
+
ledger) vit dans ``RunControl`` (cf. ``run_control.py``),
|
| 59 |
+
passé séparément à l'adapter par le runner.
|
| 60 |
"""
|
| 61 |
|
| 62 |
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
|
|
| 65 |
code_version: str = Field(min_length=1, max_length=128)
|
| 66 |
pipeline_name: str = Field(min_length=1, max_length=128)
|
| 67 |
workspace_uri: str | None = Field(default=None, max_length=2048)
|
| 68 |
+
deadline: Deadline = Field(default_factory=Deadline.infinite)
|
| 69 |
|
| 70 |
|
| 71 |
class StepResult(BaseModel):
|
|
@@ -86,7 +86,16 @@ def _layer_index(name: str) -> int:
|
|
| 86 |
#: des couches plus internes). Liste blanche stricte ; tout import
|
| 87 |
#: hors stdlib qui n'est pas dans cette liste fait échouer le test.
|
| 88 |
EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
|
| 89 |
-
"domain": frozenset({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
"evaluation": frozenset({
|
| 91 |
"pydantic", "typing_extensions", "annotated_types",
|
| 92 |
"numpy", "scipy", "jiwer", "rapidfuzz",
|
|
|
|
| 86 |
#: des couches plus internes). Liste blanche stricte ; tout import
|
| 87 |
#: hors stdlib qui n'est pas dans cette liste fait échouer le test.
|
| 88 |
EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
|
| 89 |
+
"domain": frozenset({
|
| 90 |
+
"pydantic", "typing_extensions", "annotated_types",
|
| 91 |
+
# ``pydantic_core`` est le backend Rust de ``pydantic`` et
|
| 92 |
+
# son API officielle pour définir des types valeur custom
|
| 93 |
+
# via ``__get_pydantic_core_schema__`` (cf.
|
| 94 |
+
# ``picarones/domain/deadline.py``). Sémantiquement
|
| 95 |
+
# indissociable de ``pydantic`` — pas une lib externe
|
| 96 |
+
# distincte au sens de la whitelist.
|
| 97 |
+
"pydantic_core",
|
| 98 |
+
}),
|
| 99 |
"evaluation": frozenset({
|
| 100 |
"pydantic", "typing_extensions", "annotated_types",
|
| 101 |
"numpy", "scipy", "jiwer", "rapidfuzz",
|
|
@@ -19,7 +19,11 @@ from picarones.adapters.llm.base import LLMAdapterError
|
|
| 19 |
from picarones.adapters.ocr.base import OCRAdapterError
|
| 20 |
from picarones.adapters.storage import JobStoreError
|
| 21 |
from picarones.adapters.vlm.base import VLMAdapterError
|
| 22 |
-
from picarones.domain.errors import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
class TestErrorInheritance:
|
|
@@ -65,3 +69,37 @@ class TestPolymorphicCatch:
|
|
| 65 |
def test_picarones_catches_jobstore(self) -> None:
|
| 66 |
with pytest.raises(PicaronesError):
|
| 67 |
raise JobStoreError("store boom")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from picarones.adapters.ocr.base import OCRAdapterError
|
| 20 |
from picarones.adapters.storage import JobStoreError
|
| 21 |
from picarones.adapters.vlm.base import VLMAdapterError
|
| 22 |
+
from picarones.domain.errors import (
|
| 23 |
+
AdapterStepError,
|
| 24 |
+
DeadlineExceeded,
|
| 25 |
+
PicaronesError,
|
| 26 |
+
)
|
| 27 |
|
| 28 |
|
| 29 |
class TestErrorInheritance:
|
|
|
|
| 69 |
def test_picarones_catches_jobstore(self) -> None:
|
| 70 |
with pytest.raises(PicaronesError):
|
| 71 |
raise JobStoreError("store boom")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TestDeadlineExceeded:
|
| 75 |
+
"""``DeadlineExceeded`` est une sous-classe d'``AdapterStepError``.
|
| 76 |
+
|
| 77 |
+
Permet à un adapter de signaler explicitement l'expiration de
|
| 78 |
+
la deadline propagée via ``context.deadline``, distincte d'un
|
| 79 |
+
échec fonctionnel arbitraire — le runner s'en sert pour
|
| 80 |
+
catégoriser la cause de terminaison.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
def test_inherits_adapter_step_error(self) -> None:
|
| 84 |
+
assert issubclass(DeadlineExceeded, AdapterStepError)
|
| 85 |
+
assert issubclass(DeadlineExceeded, PicaronesError)
|
| 86 |
+
|
| 87 |
+
def test_picarones_catches_deadline_exceeded(self) -> None:
|
| 88 |
+
with pytest.raises(PicaronesError):
|
| 89 |
+
raise DeadlineExceeded("deadline boom")
|
| 90 |
+
|
| 91 |
+
def test_adapter_step_error_catches_deadline_exceeded(self) -> None:
|
| 92 |
+
"""Un caller qui catche ``AdapterStepError`` génériquement
|
| 93 |
+
attrape aussi ``DeadlineExceeded`` — backwards compatible
|
| 94 |
+
avec le code existant qui ne distingue pas la cause."""
|
| 95 |
+
with pytest.raises(AdapterStepError):
|
| 96 |
+
raise DeadlineExceeded("deadline boom")
|
| 97 |
+
|
| 98 |
+
def test_deadline_exceeded_not_caught_by_ocr_specific(self) -> None:
|
| 99 |
+
"""``DeadlineExceeded`` est *transverse* aux domaines OCR /
|
| 100 |
+
LLM / VLM — il ne descend pas de leur sous-classe spécifique.
|
| 101 |
+
Un caller qui catche ``OCRAdapterError`` doit aussi catcher
|
| 102 |
+
``DeadlineExceeded`` séparément s'il veut distinguer."""
|
| 103 |
+
assert not issubclass(DeadlineExceeded, OCRAdapterError)
|
| 104 |
+
assert not issubclass(DeadlineExceeded, LLMAdapterError)
|
| 105 |
+
assert not issubclass(DeadlineExceeded, VLMAdapterError)
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests de l'intégration ``Deadline`` ↔ ``RunContext`` (Pydantic).
|
| 2 |
+
|
| 3 |
+
Phase 2 du chantier ADR-0001 : ``RunContext`` porte désormais un
|
| 4 |
+
champ ``deadline: Deadline`` (default infinite). Ces tests vérifient :
|
| 5 |
+
|
| 6 |
+
- Construction par défaut (deadline infinie).
|
| 7 |
+
- Construction avec ``deadline`` explicite (Deadline ou dict).
|
| 8 |
+
- ``model_dump_json`` / ``model_validate_json`` round-trip avec
|
| 9 |
+
sémantique cross-process correcte (overhead de dispatch non
|
| 10 |
+
facturé).
|
| 11 |
+
- ``model_dump(mode="python")`` retourne un dict avec
|
| 12 |
+
``remaining_seconds``.
|
| 13 |
+
- ``frozen=True, extra="forbid"`` préservés.
|
| 14 |
+
- Non-régression : RunContext sans deadline explicite continue de
|
| 15 |
+
fonctionner.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
from pydantic import ValidationError
|
| 25 |
+
|
| 26 |
+
from picarones.domain.deadline import Deadline
|
| 27 |
+
from picarones.pipeline.types import RunContext
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class TestDefaultDeadline:
|
| 31 |
+
def test_no_deadline_kwarg_defaults_to_infinite(self) -> None:
|
| 32 |
+
"""Compat ascendante : un caller qui n'a jamais entendu parler
|
| 33 |
+
de Deadline construit ``RunContext`` sans paramètre, et reçoit
|
| 34 |
+
une deadline infinie (aucune contrainte temporelle)."""
|
| 35 |
+
ctx = RunContext(
|
| 36 |
+
document_id="d1",
|
| 37 |
+
code_version="0.10.0",
|
| 38 |
+
pipeline_name="p",
|
| 39 |
+
)
|
| 40 |
+
assert ctx.deadline.is_infinite is True
|
| 41 |
+
|
| 42 |
+
def test_explicit_infinite(self) -> None:
|
| 43 |
+
ctx = RunContext(
|
| 44 |
+
document_id="d1",
|
| 45 |
+
code_version="0.10.0",
|
| 46 |
+
pipeline_name="p",
|
| 47 |
+
deadline=Deadline.infinite(),
|
| 48 |
+
)
|
| 49 |
+
assert ctx.deadline.is_infinite is True
|
| 50 |
+
|
| 51 |
+
def test_explicit_finite(self) -> None:
|
| 52 |
+
d = Deadline.in_seconds(30.0)
|
| 53 |
+
ctx = RunContext(
|
| 54 |
+
document_id="d1",
|
| 55 |
+
code_version="0.10.0",
|
| 56 |
+
pipeline_name="p",
|
| 57 |
+
deadline=d,
|
| 58 |
+
)
|
| 59 |
+
assert ctx.deadline.is_infinite is False
|
| 60 |
+
# Même objet ou un objet équivalent : Pydantic peut copier en
|
| 61 |
+
# interne. Vérifions la sémantique.
|
| 62 |
+
r = ctx.deadline.remaining_seconds()
|
| 63 |
+
assert r is not None and 29.0 < r <= 30.0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestPydanticValidation:
|
| 67 |
+
def test_validate_from_dict(self) -> None:
|
| 68 |
+
"""Un payload JSON arrivé avec ``{"remaining_seconds": 30.0}``
|
| 69 |
+
doit être validé comme un ``Deadline``."""
|
| 70 |
+
payload = {
|
| 71 |
+
"document_id": "d1",
|
| 72 |
+
"code_version": "0.10.0",
|
| 73 |
+
"pipeline_name": "p",
|
| 74 |
+
"deadline": {"remaining_seconds": 30.0},
|
| 75 |
+
}
|
| 76 |
+
ctx = RunContext.model_validate(payload)
|
| 77 |
+
assert isinstance(ctx.deadline, Deadline)
|
| 78 |
+
r = ctx.deadline.remaining_seconds()
|
| 79 |
+
assert r is not None and 29.0 < r <= 30.0
|
| 80 |
+
|
| 81 |
+
def test_validate_from_dict_infinite(self) -> None:
|
| 82 |
+
payload = {
|
| 83 |
+
"document_id": "d1",
|
| 84 |
+
"code_version": "0.10.0",
|
| 85 |
+
"pipeline_name": "p",
|
| 86 |
+
"deadline": {"remaining_seconds": None},
|
| 87 |
+
}
|
| 88 |
+
ctx = RunContext.model_validate(payload)
|
| 89 |
+
assert ctx.deadline.is_infinite is True
|
| 90 |
+
|
| 91 |
+
def test_validate_rejects_garbage(self) -> None:
|
| 92 |
+
"""Un type non reconnu doit être rejeté avec une erreur
|
| 93 |
+
Pydantic claire — pas absorbé silencieusement."""
|
| 94 |
+
payload = {
|
| 95 |
+
"document_id": "d1",
|
| 96 |
+
"code_version": "0.10.0",
|
| 97 |
+
"pipeline_name": "p",
|
| 98 |
+
"deadline": "60s", # string non reconnu
|
| 99 |
+
}
|
| 100 |
+
with pytest.raises((ValidationError, ValueError)):
|
| 101 |
+
RunContext.model_validate(payload)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TestSerializationRoundtrip:
|
| 105 |
+
def test_dump_python_yields_dict_form(self) -> None:
|
| 106 |
+
d = Deadline.in_seconds(20.0)
|
| 107 |
+
ctx = RunContext(
|
| 108 |
+
document_id="d1",
|
| 109 |
+
code_version="0.10.0",
|
| 110 |
+
pipeline_name="p",
|
| 111 |
+
deadline=d,
|
| 112 |
+
)
|
| 113 |
+
out = ctx.model_dump(mode="python")
|
| 114 |
+
assert "deadline" in out
|
| 115 |
+
assert "remaining_seconds" in out["deadline"]
|
| 116 |
+
# Le budget sérialisé est ~20s.
|
| 117 |
+
r = out["deadline"]["remaining_seconds"]
|
| 118 |
+
assert r is not None and 19.0 < r <= 20.0
|
| 119 |
+
|
| 120 |
+
def test_dump_json_yields_dict_form(self) -> None:
|
| 121 |
+
d = Deadline.in_seconds(15.0)
|
| 122 |
+
ctx = RunContext(
|
| 123 |
+
document_id="d1",
|
| 124 |
+
code_version="0.10.0",
|
| 125 |
+
pipeline_name="p",
|
| 126 |
+
deadline=d,
|
| 127 |
+
)
|
| 128 |
+
s = ctx.model_dump_json()
|
| 129 |
+
parsed = json.loads(s)
|
| 130 |
+
assert "deadline" in parsed
|
| 131 |
+
assert parsed["deadline"]["remaining_seconds"] is not None
|
| 132 |
+
assert 14.0 < parsed["deadline"]["remaining_seconds"] <= 15.0
|
| 133 |
+
|
| 134 |
+
def test_dump_infinite_yields_null(self) -> None:
|
| 135 |
+
ctx = RunContext(
|
| 136 |
+
document_id="d1",
|
| 137 |
+
code_version="0.10.0",
|
| 138 |
+
pipeline_name="p",
|
| 139 |
+
)
|
| 140 |
+
out = ctx.model_dump(mode="python")
|
| 141 |
+
assert out["deadline"] == {"remaining_seconds": None}
|
| 142 |
+
|
| 143 |
+
def test_json_roundtrip_preserves_handoff_semantics(self) -> None:
|
| 144 |
+
"""Propriété critique : entre serialize et deserialize, le
|
| 145 |
+
budget visible côté receveur reste cohérent avec le moment
|
| 146 |
+
du handoff. Sleep de 300ms entre les deux = perdu côté
|
| 147 |
+
wall-clock mais pas côté contrat (le receveur voit ~30s)."""
|
| 148 |
+
original = Deadline.in_seconds(30.0)
|
| 149 |
+
ctx = RunContext(
|
| 150 |
+
document_id="d1",
|
| 151 |
+
code_version="0.10.0",
|
| 152 |
+
pipeline_name="p",
|
| 153 |
+
deadline=original,
|
| 154 |
+
)
|
| 155 |
+
json_str = ctx.model_dump_json()
|
| 156 |
+
|
| 157 |
+
# Simule un dispatch lent.
|
| 158 |
+
time.sleep(0.3)
|
| 159 |
+
|
| 160 |
+
rebuilt = RunContext.model_validate_json(json_str)
|
| 161 |
+
r = rebuilt.deadline.remaining_seconds()
|
| 162 |
+
assert r is not None
|
| 163 |
+
# Le receveur voit ~30s (le sleep n'a pas mangé le budget).
|
| 164 |
+
# La valeur exacte dépend du timing entre serialize et
|
| 165 |
+
# validate — sera entre handoff_remaining et handoff_remaining
|
| 166 |
+
# car le receveur reconstruit à validate_time.
|
| 167 |
+
assert 29.5 < r <= 30.0, (
|
| 168 |
+
f"deadline reçue {r:.3f}s — dispatch overhead facturé "
|
| 169 |
+
f"alors qu'il ne devrait pas"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TestFrozenAndForbidExtra:
|
| 174 |
+
def test_runcontext_still_frozen(self) -> None:
|
| 175 |
+
ctx = RunContext(
|
| 176 |
+
document_id="d1",
|
| 177 |
+
code_version="0.10.0",
|
| 178 |
+
pipeline_name="p",
|
| 179 |
+
)
|
| 180 |
+
with pytest.raises(ValidationError):
|
| 181 |
+
ctx.document_id = "d2" # type: ignore[misc]
|
| 182 |
+
|
| 183 |
+
def test_runcontext_still_forbid_extra(self) -> None:
|
| 184 |
+
with pytest.raises(ValidationError, match="forbidden|extra"):
|
| 185 |
+
RunContext(
|
| 186 |
+
document_id="d1",
|
| 187 |
+
code_version="0.10.0",
|
| 188 |
+
pipeline_name="p",
|
| 189 |
+
unknown_field="x", # type: ignore[call-arg]
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def test_deadline_field_cannot_be_mutated(self) -> None:
|
| 193 |
+
d = Deadline.in_seconds(10.0)
|
| 194 |
+
ctx = RunContext(
|
| 195 |
+
document_id="d1",
|
| 196 |
+
code_version="0.10.0",
|
| 197 |
+
pipeline_name="p",
|
| 198 |
+
deadline=d,
|
| 199 |
+
)
|
| 200 |
+
# Pydantic frozen → on ne peut pas réaffecter le champ.
|
| 201 |
+
with pytest.raises(ValidationError):
|
| 202 |
+
ctx.deadline = Deadline.infinite() # type: ignore[misc]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class TestNonRegression:
|
| 206 |
+
"""Vérifie que l'ajout du champ ``deadline`` n'a pas cassé
|
| 207 |
+
l'API existante pour les callers qui construisent ``RunContext``
|
| 208 |
+
sans le paramètre."""
|
| 209 |
+
|
| 210 |
+
def test_legacy_construction_still_works(self) -> None:
|
| 211 |
+
"""Les ~31 constructions de RunContext dans les tests
|
| 212 |
+
existants ne passent pas ``deadline`` — elles doivent
|
| 213 |
+
continuer à compiler et à fonctionner."""
|
| 214 |
+
ctx = RunContext(
|
| 215 |
+
document_id="d1",
|
| 216 |
+
code_version="0.10.0",
|
| 217 |
+
pipeline_name="p",
|
| 218 |
+
)
|
| 219 |
+
# Tous les champs anciens présents et corrects.
|
| 220 |
+
assert ctx.document_id == "d1"
|
| 221 |
+
assert ctx.code_version == "0.10.0"
|
| 222 |
+
assert ctx.pipeline_name == "p"
|
| 223 |
+
assert ctx.workspace_uri is None
|
| 224 |
+
|
| 225 |
+
def test_legacy_construction_with_workspace(self) -> None:
|
| 226 |
+
ctx = RunContext(
|
| 227 |
+
document_id="d1",
|
| 228 |
+
code_version="0.10.0",
|
| 229 |
+
pipeline_name="p",
|
| 230 |
+
workspace_uri="/tmp/runtime",
|
| 231 |
+
)
|
| 232 |
+
assert ctx.workspace_uri == "/tmp/runtime"
|
| 233 |
+
assert ctx.deadline.is_infinite is True
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests de ``picarones.pipeline.run_control.RunControl``.
|
| 2 |
+
|
| 3 |
+
Le ``RunControl`` est l'état runtime mutable d'un step en cours
|
| 4 |
+
d'exécution. Couvre :
|
| 5 |
+
|
| 6 |
+
- Création (avec / sans cancel_event injecté).
|
| 7 |
+
- Cancellation coopérative via ``cancel_event``.
|
| 8 |
+
- ``raise_if_cancelled()`` lève ``RunCancelledError`` quand signalé.
|
| 9 |
+
- ``register_cancel_handle`` enregistre des handles (thread-safe).
|
| 10 |
+
- ``trigger_cancel`` appelle tous les handles + set l'event.
|
| 11 |
+
- Idempotence de ``trigger_cancel``.
|
| 12 |
+
- Un handle enregistré après ``trigger_cancel`` est appelé immédiatement.
|
| 13 |
+
- Un handle qui lève n'empêche pas les autres d'être appelés.
|
| 14 |
+
- Thread-safety : enregistrement concurrent depuis plusieurs threads.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import threading
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
from picarones.domain.errors import PicaronesError
|
| 26 |
+
from picarones.pipeline.run_control import RunCancelledError, RunControl
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestConstruction:
|
| 30 |
+
def test_default_creates_local_cancel_event(self) -> None:
|
| 31 |
+
rc = RunControl()
|
| 32 |
+
assert isinstance(rc.cancel_event, threading.Event)
|
| 33 |
+
assert rc.is_cancelled() is False
|
| 34 |
+
assert rc.cancel_triggered is False
|
| 35 |
+
|
| 36 |
+
def test_external_cancel_event_is_shared(self) -> None:
|
| 37 |
+
event = threading.Event()
|
| 38 |
+
rc = RunControl(cancel_event=event)
|
| 39 |
+
assert rc.cancel_event is event
|
| 40 |
+
# Setting the external event reflects in the control.
|
| 41 |
+
event.set()
|
| 42 |
+
assert rc.is_cancelled() is True
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TestCancellationCooperative:
|
| 46 |
+
def test_is_cancelled_reflects_event(self) -> None:
|
| 47 |
+
rc = RunControl()
|
| 48 |
+
assert rc.is_cancelled() is False
|
| 49 |
+
rc.cancel_event.set()
|
| 50 |
+
assert rc.is_cancelled() is True
|
| 51 |
+
|
| 52 |
+
def test_raise_if_cancelled_no_raise_when_not_cancelled(self) -> None:
|
| 53 |
+
rc = RunControl()
|
| 54 |
+
rc.raise_if_cancelled() # ne doit pas lever
|
| 55 |
+
|
| 56 |
+
def test_raise_if_cancelled_raises_when_signalled(self) -> None:
|
| 57 |
+
rc = RunControl()
|
| 58 |
+
rc.cancel_event.set()
|
| 59 |
+
with pytest.raises(RunCancelledError, match="cancel_event signalled"):
|
| 60 |
+
rc.raise_if_cancelled()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestCancelHandles:
|
| 64 |
+
def test_register_and_trigger_calls_handle(self) -> None:
|
| 65 |
+
rc = RunControl()
|
| 66 |
+
called = []
|
| 67 |
+
|
| 68 |
+
def my_close() -> None:
|
| 69 |
+
called.append("closed")
|
| 70 |
+
|
| 71 |
+
rc.register_cancel_handle(my_close)
|
| 72 |
+
assert called == [] # pas encore appelé
|
| 73 |
+
rc.trigger_cancel()
|
| 74 |
+
assert called == ["closed"]
|
| 75 |
+
assert rc.is_cancelled() is True
|
| 76 |
+
assert rc.cancel_triggered is True
|
| 77 |
+
|
| 78 |
+
def test_multiple_handles_called_in_order(self) -> None:
|
| 79 |
+
rc = RunControl()
|
| 80 |
+
order: list[int] = []
|
| 81 |
+
|
| 82 |
+
rc.register_cancel_handle(lambda: order.append(1))
|
| 83 |
+
rc.register_cancel_handle(lambda: order.append(2))
|
| 84 |
+
rc.register_cancel_handle(lambda: order.append(3))
|
| 85 |
+
rc.trigger_cancel()
|
| 86 |
+
assert order == [1, 2, 3]
|
| 87 |
+
|
| 88 |
+
def test_trigger_cancel_is_idempotent(self) -> None:
|
| 89 |
+
rc = RunControl()
|
| 90 |
+
called = []
|
| 91 |
+
rc.register_cancel_handle(lambda: called.append("once"))
|
| 92 |
+
rc.trigger_cancel()
|
| 93 |
+
rc.trigger_cancel() # second call should be no-op
|
| 94 |
+
assert called == ["once"]
|
| 95 |
+
|
| 96 |
+
def test_handle_registered_after_trigger_called_immediately(self) -> None:
|
| 97 |
+
rc = RunControl()
|
| 98 |
+
rc.trigger_cancel() # set state first
|
| 99 |
+
called = []
|
| 100 |
+
rc.register_cancel_handle(lambda: called.append("late"))
|
| 101 |
+
assert called == ["late"], (
|
| 102 |
+
"un handle enregistré après cancel doit être appelé "
|
| 103 |
+
"immédiatement — l'adapter ne peut pas s'enregistrer et "
|
| 104 |
+
"continuer comme si de rien n'était"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def test_failing_handle_does_not_block_others(
|
| 108 |
+
self, caplog: pytest.LogCaptureFixture,
|
| 109 |
+
) -> None:
|
| 110 |
+
"""Un handle qui lève (ex : socket déjà fermé) ne doit pas
|
| 111 |
+
empêcher les handles suivants d'être appelés."""
|
| 112 |
+
rc = RunControl()
|
| 113 |
+
called: list[str] = []
|
| 114 |
+
|
| 115 |
+
def good_handle_1() -> None:
|
| 116 |
+
called.append("1")
|
| 117 |
+
|
| 118 |
+
def bad_handle() -> None:
|
| 119 |
+
raise RuntimeError("intentional failure")
|
| 120 |
+
|
| 121 |
+
def good_handle_2() -> None:
|
| 122 |
+
called.append("2")
|
| 123 |
+
|
| 124 |
+
rc.register_cancel_handle(good_handle_1)
|
| 125 |
+
rc.register_cancel_handle(bad_handle)
|
| 126 |
+
rc.register_cancel_handle(good_handle_2)
|
| 127 |
+
with caplog.at_level(logging.WARNING):
|
| 128 |
+
rc.trigger_cancel()
|
| 129 |
+
assert called == ["1", "2"], (
|
| 130 |
+
"good_handle_2 doit être appelé même si bad_handle a levé"
|
| 131 |
+
)
|
| 132 |
+
# Le warning doit être loggé explicitement (pas absorbé silently).
|
| 133 |
+
assert any(
|
| 134 |
+
"cancel handle" in rec.message and "intentional failure" in rec.message
|
| 135 |
+
for rec in caplog.records
|
| 136 |
+
), "le log warning doit mentionner le handle qui a échoué"
|
| 137 |
+
|
| 138 |
+
def test_register_rejects_non_callable(self) -> None:
|
| 139 |
+
rc = RunControl()
|
| 140 |
+
with pytest.raises(PicaronesError, match="doit être callable"):
|
| 141 |
+
rc.register_cancel_handle("not a function") # type: ignore[arg-type]
|
| 142 |
+
|
| 143 |
+
def test_cancel_event_set_before_handles_invoked(self) -> None:
|
| 144 |
+
"""Quand un handle est appelé, ``cancel_event`` est déjà set —
|
| 145 |
+
ainsi un handle qui check ``is_cancelled()`` en cascade voit
|
| 146 |
+
l'état cohérent."""
|
| 147 |
+
rc = RunControl()
|
| 148 |
+
observed_cancel_state: list[bool] = []
|
| 149 |
+
|
| 150 |
+
def my_handle() -> None:
|
| 151 |
+
observed_cancel_state.append(rc.is_cancelled())
|
| 152 |
+
|
| 153 |
+
rc.register_cancel_handle(my_handle)
|
| 154 |
+
rc.trigger_cancel()
|
| 155 |
+
assert observed_cancel_state == [True]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class TestThreadSafety:
|
| 159 |
+
def test_concurrent_register_and_trigger(self) -> None:
|
| 160 |
+
"""Stress test : 10 threads enregistrent des handles tandis
|
| 161 |
+
qu'un 11e appelle trigger. Aucun handle ne doit être perdu
|
| 162 |
+
ni double-appelé (modulo le contrat "registered-after-trigger
|
| 163 |
+
appelé immédiatement")."""
|
| 164 |
+
rc = RunControl()
|
| 165 |
+
n_handles = 20
|
| 166 |
+
called: list[int] = []
|
| 167 |
+
called_lock = threading.Lock()
|
| 168 |
+
|
| 169 |
+
def make_handle(i: int):
|
| 170 |
+
def handle() -> None:
|
| 171 |
+
with called_lock:
|
| 172 |
+
called.append(i)
|
| 173 |
+
return handle
|
| 174 |
+
|
| 175 |
+
start_barrier = threading.Barrier(n_handles + 1)
|
| 176 |
+
|
| 177 |
+
def register_thread(i: int) -> None:
|
| 178 |
+
start_barrier.wait()
|
| 179 |
+
time.sleep(0.001 * (i % 3)) # un peu de jitter
|
| 180 |
+
rc.register_cancel_handle(make_handle(i))
|
| 181 |
+
|
| 182 |
+
def trigger_thread() -> None:
|
| 183 |
+
start_barrier.wait()
|
| 184 |
+
time.sleep(0.01) # laisser quelques registers passer avant
|
| 185 |
+
rc.trigger_cancel()
|
| 186 |
+
|
| 187 |
+
registers = [
|
| 188 |
+
threading.Thread(target=register_thread, args=(i,))
|
| 189 |
+
for i in range(n_handles)
|
| 190 |
+
]
|
| 191 |
+
trigger = threading.Thread(target=trigger_thread)
|
| 192 |
+
for t in registers:
|
| 193 |
+
t.start()
|
| 194 |
+
trigger.start()
|
| 195 |
+
for t in registers:
|
| 196 |
+
t.join(timeout=5.0)
|
| 197 |
+
trigger.join(timeout=5.0)
|
| 198 |
+
|
| 199 |
+
# Tous les handles doivent avoir été appelés (qu'ils aient
|
| 200 |
+
# été enregistrés avant ou après trigger).
|
| 201 |
+
assert sorted(called) == list(range(n_handles)), (
|
| 202 |
+
f"handles appelés : {sorted(called)} ; attendu "
|
| 203 |
+
f"{list(range(n_handles))}"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
def test_concurrent_trigger_calls_idempotent(self) -> None:
|
| 207 |
+
"""Plusieurs threads appelant trigger en même temps : un
|
| 208 |
+
seul ensemble de handles appelé."""
|
| 209 |
+
rc = RunControl()
|
| 210 |
+
called: list[int] = []
|
| 211 |
+
called_lock = threading.Lock()
|
| 212 |
+
|
| 213 |
+
for i in range(5):
|
| 214 |
+
def make_h(idx: int):
|
| 215 |
+
def h() -> None:
|
| 216 |
+
with called_lock:
|
| 217 |
+
called.append(idx)
|
| 218 |
+
return h
|
| 219 |
+
rc.register_cancel_handle(make_h(i))
|
| 220 |
+
|
| 221 |
+
def trigger() -> None:
|
| 222 |
+
rc.trigger_cancel()
|
| 223 |
+
|
| 224 |
+
threads = [threading.Thread(target=trigger) for _ in range(10)]
|
| 225 |
+
for t in threads:
|
| 226 |
+
t.start()
|
| 227 |
+
for t in threads:
|
| 228 |
+
t.join(timeout=5.0)
|
| 229 |
+
|
| 230 |
+
assert sorted(called) == [0, 1, 2, 3, 4]
|