Coverage for src/taipanstack/resilience/adaptive/bulkhead.py: 100%

112 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Bulkhead pattern — concurrency isolation via semaphore. 

3 

4Limits the number of concurrent executions to prevent a single 

5failing dependency from consuming all available resources. 

6""" 

7 

8from __future__ import annotations 

9 

10import asyncio 

11import contextlib 

12import logging 

13import math 

14from collections.abc import Awaitable, Callable, Coroutine 

15from typing import ParamSpec, TypeVar 

16 

17from taipanstack.core.result import Err, Ok, Result 

18 

19logger = logging.getLogger("taipanstack.resilience.adaptive.bulkhead") 

20 

21T = TypeVar("T") 

22P = ParamSpec("P") 

23 

24 

25class BulkheadFullError(Exception): 

26 """Raised when the bulkhead queue is at capacity.""" 

27 

28 def __init__(self, name: str, max_concurrent: int, max_queue: int) -> None: 

29 """Initialize BulkheadFullError. 

30 

31 Args: 

32 name: Bulkhead name. 

33 max_concurrent: Concurrency limit. 

34 max_queue: Queue limit. 

35 

36 """ 

37 self.bulkhead_name = name 

38 self.max_concurrent = max_concurrent 

39 self.max_queue = max_queue 

40 super().__init__( 

41 f"Bulkhead '{name}' is full " 

42 f"(max_concurrent={max_concurrent}, max_queue={max_queue})", 

43 ) 

44 

45 

46class Bulkhead: 

47 """Concurrency limiter using ``asyncio.Semaphore``. 

48 

49 Limits the number of concurrent executions of a callable. 

50 Excess callers are queued up to ``max_queue``; beyond that 

51 a ``BulkheadFullError`` is returned. 

52 

53 Args: 

54 name: Identifier for logging. 

55 max_concurrent: Maximum concurrent executions. 

56 max_queue: Maximum queued callers beyond concurrent limit. 

57 timeout: Seconds to wait for a permit before timing out. 

58 

59 Example: 

60 >>> bulk = Bulkhead("db", max_concurrent=5, max_queue=10) 

61 >>> result = await bulk.execute(fetch_data, user_id) 

62 

63 """ 

64 

65 def __init__( 

66 self, 

67 name: str = "default", 

68 *, 

69 max_concurrent: int = 10, 

70 max_queue: int = 50, 

71 timeout: float = 30.0, 

72 ) -> None: 

73 """Initialize the bulkhead. 

74 

75 Args: 

76 name: Bulkhead name. 

77 max_concurrent: Concurrency limit. 

78 max_queue: Queue limit. 

79 timeout: Permit acquisition timeout. 

80 

81 """ 

82 self.name = name 

83 self._max_concurrent = self._validate_max_concurrent(max_concurrent) 

84 self._max_queue = self._validate_max_queue(max_queue) 

85 self._timeout = self._validate_timeout(timeout) 

86 self._semaphore = asyncio.Semaphore(self._max_concurrent) 

87 self._queued = 0 

88 self._active = 0 

89 

90 @staticmethod 

91 def _validate_max_concurrent(value: int) -> int: 

92 if not isinstance(value, int) or isinstance(value, bool) or value < 1: 

93 raise ValueError("max_concurrent must be an integer >= 1") 

94 return value 

95 

96 @staticmethod 

97 def _validate_max_queue(value: int) -> int: 

98 if not isinstance(value, int) or isinstance(value, bool) or value < 0: 

99 raise ValueError("max_queue must be an integer >= 0") 

100 return value 

101 

102 @staticmethod 

103 def _validate_timeout(value: float) -> float: 

104 if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: 

105 raise ValueError("timeout must be a finite non-negative number") 

106 return float(value) 

107 

108 @property 

109 def available_permits(self) -> int: 

110 """Number of available concurrency permits.""" 

111 return self._max_concurrent - self._active 

112 

113 @property 

114 def queued(self) -> int: 

115 """Number of callers currently waiting in the queue.""" 

116 return self._queued 

117 

118 @property 

119 def active(self) -> int: 

120 """Number of currently executing tasks.""" 

121 return self._active 

122 

123 async def _cleanup_acquire_task( 

124 self, 

125 acquire_task: asyncio.Task[bool], 

126 ) -> None: 

127 """Helper to cancel an acquisition task and release semaphore if acquired.""" 

128 acquire_task.cancel() 

129 with contextlib.suppress(asyncio.CancelledError): 

130 await acquire_task 

131 self._semaphore.release() 

132 

133 def _get_acquire_coro(self) -> Result[Coroutine[object, object, bool], Exception]: 

134 """Attempt to get the semaphore acquire coroutine.""" 

135 try: 

136 return Ok(self._semaphore.acquire()) 

137 except (RuntimeError, OSError, MemoryError) as e: 

138 return Err(RuntimeError(f"Resource exhaustion: {e!s}")) 

139 except Exception as e: 

140 return Err(RuntimeError(f"Resource exhaustion: {e!s}")) 

141 

142 def _close_coro_if_needed(self, coro: Coroutine[object, object, bool]) -> None: 

143 if asyncio.iscoroutine(coro): 

144 coro.close() 

145 

146 async def _wait_for_coro( 

147 self, coro: Coroutine[object, object, bool] 

148 ) -> Result[None, Exception]: 

149 """Wait for the acquire task to complete, handling errors and cleanup.""" 

150 try: 

151 # We explicitly check for exception during the coroutine creation or wait 

152 acquire_task: asyncio.Task[bool] = asyncio.create_task(coro) 

153 return await _wait_for_permit_task( 

154 acquire_task, 

155 self._timeout, 

156 self.name, 

157 self._cleanup_acquire_task, 

158 ) 

159 except Exception as e: 

160 self._close_coro_if_needed(coro) 

161 return Err(RuntimeError(f"Resource exhaustion: {e!s}")) 

162 

163 async def _acquire_permit(self) -> Result[None, Exception]: 

164 """Wait for and acquire a concurrency permit.""" 

165 coro_result = self._get_acquire_coro() 

166 if isinstance(coro_result, Err): 

167 return coro_result 

168 

169 return await self._wait_for_coro(coro_result.unwrap()) 

170 

171 async def _attempt_acquire(self) -> Result[None, Exception]: 

172 """Attempt to queue and acquire a permit.""" 

173 self._queued += 1 

174 try: 

175 permit_result = await self._acquire_permit() 

176 if isinstance(permit_result, Err): 

177 return permit_result 

178 return Ok(None) 

179 finally: 

180 self._queued -= 1 

181 

182 async def _execute_with_permit( 

183 self, 

184 fn: Callable[P, Awaitable[T]], 

185 *args: P.args, 

186 **kwargs: P.kwargs, 

187 ) -> Result[T, Exception]: 

188 """Execute the function within the acquired permit.""" 

189 self._active += 1 

190 try: 

191 result = await fn(*args, **kwargs) 

192 return Ok(result) 

193 except Exception as exc: 

194 logger.warning( 

195 "Bulkhead '%s' execution failed: %s", 

196 self.name, 

197 exc, 

198 ) 

199 return Err(exc) 

200 finally: 

201 self._active -= 1 

202 self._semaphore.release() 

203 

204 async def execute( 

205 self, 

206 fn: Callable[P, Awaitable[T]], 

207 *args: P.args, 

208 **kwargs: P.kwargs, 

209 ) -> Result[T, Exception]: 

210 """Execute a callable within bulkhead limits. 

211 

212 Args: 

213 fn: Async callable to execute. 

214 *args: Positional arguments for fn. 

215 **kwargs: Keyword arguments for fn. 

216 

217 Returns: 

218 ``Ok(result)`` on success, ``Err`` on failure. 

219 

220 """ 

221 # Check queue capacity 

222 if self._queued >= self._max_queue: 

223 return Err( 

224 BulkheadFullError( 

225 self.name, 

226 self._max_concurrent, 

227 self._max_queue, 

228 ), 

229 ) 

230 

231 acquire_result = await self._attempt_acquire() 

232 if isinstance(acquire_result, Err): 

233 return acquire_result 

234 

235 return await self._execute_with_permit(fn, *args, **kwargs) 

236 

237 

238async def _wait_for_permit_task( 

239 acquire_task: asyncio.Task[bool], 

240 timeout: float, 

241 name: str, 

242 cleanup_func: Callable[[asyncio.Task[bool]], Awaitable[None]], 

243) -> Result[None, Exception]: 

244 """Helper to wait for a permit task with timeout and cancellation.""" 

245 try: 

246 await asyncio.wait_for( 

247 asyncio.shield(acquire_task), 

248 timeout=timeout, 

249 ) 

250 return Ok(None) 

251 except TimeoutError: 

252 await cleanup_func(acquire_task) 

253 return Err( 

254 TimeoutError( 

255 f"Bulkhead '{name}' timed out after {timeout}s waiting for permit", 

256 ), 

257 ) 

258 except asyncio.CancelledError: 

259 await cleanup_func(acquire_task) 

260 raise