# Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, # you can obtain one at http://mozilla.org/MPL/2.0/. # SPDX-License-Identifier: MPL-2.0 """Concurrency ownership for the shared pypowsybl ``Network`` (D3, 2026-07). The backend's central bet — module-level singletons + a single shared ``Network`` that every code path variant-switches — was designed for a single-user, single-flight desktop deployment. That assumption no longer holds: FastAPI runs sync endpoints on a threadpool, the frontend fires ``Promise.all`` batches and detached-tab refreshes, and the HuggingFace Space adds genuinely concurrent visitors. Two primitives restore ownership: 1. :func:`with_network_lock` / :func:`with_network_lock_stream` — a service-level re-entrant lock (``self._network_lock``) that serializes every entry point that switches variants on the shared Network. Sync methods hold it for their whole body; streaming generators hold it **per resumption** (each phase between two ``yield``\\ s is internally variant-consistent — every switch is paired with a finally-restore — so releasing at yield points is safe, and it keeps a long discovery phase from starving diagram requests any longer than that one phase). Thread-affinity note: Starlette iterates sync streaming generators via ``iterate_in_threadpool``, which may run **each** ``next()`` on a different worker thread. An ``RLock`` must be released by the thread that acquired it, so a naive ``with lock: yield from ...`` would break — the per-step iterator below acquires and releases inside a single ``__next__`` call, which always runs on one thread. 2. A **busy gate** for study-level mutations (``/api/config``, step-1, step-2, the legacy analysis stream) — a non-blocking ``try_begin_study_mutation`` that maps to **HTTP 409** instead of queueing a second multi-second mutation behind the first. The gate is a plain ``threading.Lock`` (not an RLock) because a streaming mutation acquires it on the request thread and releases it from whatever threadpool thread finishes the stream. Lock-ordering vs the NAD-prefetch drain: entry points hold the network lock while calling ``_drain_pending_base_nad_prefetch()``, and the prefetch worker itself takes the same lock around its whole body — so the drain MUST NOT join while the lock is active (the worker may be blocked waiting on the very lock the joiner holds → 60 s stall). With the lock in place, mutual exclusion is already guaranteed by lock ownership, and stale results across ``reset()`` are discarded by the ``_prefetch_generation`` counter instead of by joining. See ``RecommenderService._drain_pending_base_nad_prefetch``. """ from __future__ import annotations import functools import logging from collections.abc import Callable, Iterator from typing import Any logger = logging.getLogger(__name__) def _resolve_lock(instance: Any) -> Any: """The service lock, or None for bare-mixin test hosts that never ran ``RecommenderService.__init__`` — the decorators degrade to no-ops there so isolated mixin tests keep working single-threaded.""" return getattr(instance, "_network_lock", None) def with_network_lock(fn: Callable) -> Callable: """Serialize a sync entry point on the service network lock. Re-entrant: nested decorated calls on the same thread (e.g. ``compute_superposition`` → ``simulate_manual_action``) are fine. """ @functools.wraps(fn) def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: lock = _resolve_lock(self) if lock is None: return fn(self, *args, **kwargs) with lock: return fn(self, *args, **kwargs) return wrapper class _LockPerStepIterator: """Iterator adapter that holds ``lock`` for each ``next()`` resumption. Acquire and release happen inside one ``__next__`` call — i.e. on a single thread — which is what makes an RLock safe even though Starlette may run successive ``next()`` calls on different threadpool threads. """ __slots__ = ("_inner", "_lock") def __init__(self, inner: Iterator, lock: Any) -> None: self._inner = inner self._lock = lock def __iter__(self) -> "_LockPerStepIterator": return self def __next__(self) -> Any: with self._lock: return next(self._inner) def close(self) -> None: # Generator cleanup may run finally-blocks that touch the # network — take the lock for those too. with self._lock: close = getattr(self._inner, "close", None) if close is not None: close() def throw(self, *exc_info: Any) -> Any: with self._lock: return self._inner.throw(*exc_info) # type: ignore[attr-defined] def with_network_lock_stream(fn: Callable) -> Callable: """Serialize a generator entry point on the service network lock, one resumption at a time (see module docstring).""" @functools.wraps(fn) def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: inner = fn(self, *args, **kwargs) lock = _resolve_lock(self) if lock is None: return inner return _LockPerStepIterator(inner, lock) return wrapper