ADR-15: Python GIL / async policy

Status: accepted (2026-05-29) Blocks: workstream D (Python packaging), workstream E (bridge)

Context

The PyO3 bindings (workstream D) expose dag-ml and dag-ml-data to Python. Long-running Rust calls (FIT_CV, REFIT, PREDICT) must not block other Python threads holding the GIL, and host controllers re-entering Python from Rust worker threads must do so safely. nirs4all uses joblib.Parallel and sklearn/BLAS thread pools; uncoordinated nesting oversubscribes the CPU (Codex hidden risk).

Decision

  1. Release the GIL on long-running calls — every binding entry point that runs a phase (fit_cv, select, refit, predict, explain) or builds/validates a bundle wraps the Rust call in py.allow_threads(...). Short metadata/validation calls keep the GIL (the acquire/release overhead would dominate).

  2. Controller re-entry contract — a host controller invoked from a Rust worker thread re-acquires the GIL (Python::with_gil) before touching Python objects. The contract is documented for controller authors: your invoke may run on a non-main thread; acquire the GIL yourself.

  3. No asyncio in v1nirs4all.run / predict / explain / retrain stay synchronous. An async facade is explicitly out of scope (descoped in the roadmap). Concurrency is achieved by GIL-released Rust scheduling, not by Python coroutines.

  4. Thread-pool ceilingnirs4all.run(n_jobs=N) maps to the dag-ml scheduler worker count. The docs instruct operators to pin BLAS/OpenMP pools (OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS) and to avoid stacking joblib.Parallel(prefer="threads") on top of an already-parallel scheduler. The bridge logs the effective worker count and detected BLAS thread count at startup so oversubscription is visible.

Consequences

  • Workstream D wraps phase calls in allow_threads; the controller ABI doc (ADR-13 worker-process context) states the GIL re-entry contract.

  • The observability spans (ADR-12) carry the worker-thread id so cross-thread controller invocations are traceable.

  • nirs4all’s existing synchronous API surface is preserved exactly.

Risk

  • Releasing the GIL exposes any unsafe shared state in host controllers. The contract is documented and the default sklearn/R adapters are process-isolated (ADR-13), so the GIL-release path only touches in-process PyO3 controllers, which are the advanced case. In-process controllers that are not thread-safe must declare thread_safe = false in their manifest; the scheduler then serializes them.