"""Thread-safe bulkhead: bounded concurrency with an optional wait queue. A bulkhead isolates a resource by capping how many calls may run at once. Callers beyond the cap either queue (up to max_queue) and wait for a slot, or are rejected immediately. This prevents a slow downstream from exhausting all threads/connections of the caller. Design notes: - State guarded by a single lock + condition variable. - active: number of in-flight calls. - queued: number of callers currently waiting for a slot. - max_concurrent >= 1, max_queue >= 0. - acquire(timeout=None) blocks up to timeout seconds; returns a release handle (call release() or use as context manager). Raises BulkheadFull if the queue is full or the wait times out; raises TimeoutError on timeout. - try_acquire() is non-blocking: returns handle or None. - Releasing a slot wakes one waiter (FIFO-ish, OS scheduling). """ from __future__ import annotations import threading import time from contextlib import contextmanager from typing import Optional class BulkheadFull(Exception): """Raised when the bulkhead cannot accept another caller.""" class _Handle: __slots__ = ("_bh", "_released") def __init__(self, bh: "Bulkhead"): self._bh = bh self._released = False def release(self) -> None: if self._released: return self._released = True self._bh._release() def __enter__(self) -> "_Handle": return self def __exit__(self, *exc) -> bool: self.release() return False class Bulkhead: def __init__(self, max_concurrent: int, max_queue: int = 0): if not isinstance(max_concurrent, int) or max_concurrent < 1: raise ValueError("max_concurrent must be an int >= 1") if not isinstance(max_queue, int) or max_queue < 0: raise ValueError("max_queue must be an int >= 0") self._max_concurrent = max_concurrent self._max_queue = max_queue self._active = 0 self._queued = 0 self._lock = threading.Lock() self._cond = threading.Condition(self._lock) @property def max_concurrent(self) -> int: return self._max_concurrent @property def max_queue(self) -> int: return self._max_queue def active(self) -> int: with self._lock: return self._active def queued(self) -> int: with self._lock: return self._queued def try_acquire(self) -> Optional[_Handle]: with self._lock: if self._active < self._max_concurrent: self._active += 1 return _Handle(self) return None def acquire(self, timeout: Optional[float] = None) -> _Handle: if timeout is not None and timeout < 0: raise ValueError("timeout must be >= 0") deadline = None if timeout is None else (time.monotonic() + timeout) with self._lock: # fast path if self._active < self._max_concurrent: self._active += 1 return _Handle(self) # need to queue if self._queued >= self._max_queue: raise BulkheadFull("bulkhead queue full") self._queued += 1 try: while self._active >= self._max_concurrent: if deadline is None: self._cond.wait() else: remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("bulkhead acquire timed out") self._cond.wait(timeout=remaining) # slot available self._queued -= 1 self._active += 1 return _Handle(self) except BaseException: # ensure queued counter consistency on any exit if self._queued > 0: self._queued -= 1 raise def _release(self) -> None: with self._lock: if self._active > 0: self._active -= 1 self._cond.notify(1) @contextmanager def slot(self, timeout: Optional[float] = None): h = self.acquire(timeout=timeout) try: yield h finally: h.release() def run(self, fn, *args, timeout: Optional[float] = None, **kwargs): with self.slot(timeout=timeout): return fn(*args, **kwargs)