Coverage for agentos/core/resource_manager.py: 0%

288 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 10:19 +0800

1"""AgentOS Resource Manager — production-grade resource lifecycle & cleanup. 

2 

3Manages: 

4- Async resource acquisition/release (connections, pools, files) 

5- Ordered shutdown (LIFO — last acquired, first released) 

6- Finalizer registry for guaranteed cleanup 

7- Health-checked resource pools 

8- Leak detection 

9 

10Design: ~330 lines, zero external deps beyond stdlib + asyncio. 

11""" 

12 

13from __future__ import annotations 

14 

15import asyncio 

16import logging 

17import time 

18from abc import ABC, abstractmethod 

19from collections.abc import AsyncIterator, Callable 

20from contextlib import asynccontextmanager 

21from dataclasses import dataclass 

22from enum import StrEnum 

23from typing import ( 

24 Any, 

25 TypeVar, 

26) 

27 

28logger = logging.getLogger(__name__) 

29 

30T = TypeVar("T") 

31 

32 

33# ============================================================================ 

34# Data types 

35# ============================================================================ 

36 

37 

38class ResourceState(StrEnum): 

39 CREATED = "created" 

40 ACTIVE = "active" 

41 CLOSING = "closing" 

42 CLOSED = "closed" 

43 ERROR = "error" 

44 

45 

46class ResourceType(StrEnum): 

47 CONNECTION = "connection" 

48 POOL = "pool" 

49 FILE = "file" 

50 LOCK = "lock" 

51 SESSION = "session" 

52 OTHER = "other" 

53 

54 

55@dataclass 

56class ResourceInfo: 

57 """Metadata about a managed resource.""" 

58 

59 name: str 

60 resource_type: ResourceType = ResourceType.OTHER 

61 state: ResourceState = ResourceState.CREATED 

62 acquired_at: float = 0.0 

63 released_at: float = 0.0 

64 leak_warn_threshold: float = 300.0 # Warn if held > 5 min 

65 

66 

67# ============================================================================ 

68# Abstract Resource 

69# ============================================================================ 

70 

71 

72class AbstractResource(ABC): 

73 """Interface for managed resources.""" 

74 

75 @abstractmethod 

76 async def close(self) -> None: 

77 """Release the underlying resource.""" 

78 

79 async def health_check(self) -> bool: 

80 """Optional health check. Default: assume healthy if not closed.""" 

81 return True 

82 

83 def on_leak_detected(self) -> None: 

84 """Callback when resource appears to be leaked.""" 

85 logger.warning("Resource leak suspected: %s", self) 

86 

87 

88# ============================================================================ 

89# Managed Resource wrapper 

90# ============================================================================ 

91 

92 

93class ManagedResource(AbstractResource): 

94 """Wraps any async-closeable object into a managed resource.""" 

95 

96 def __init__( 

97 self, 

98 resource: Any, 

99 close_fn: Callable[[Any], Any] | None = None, 

100 name: str = "unnamed", 

101 resource_type: ResourceType = ResourceType.OTHER, 

102 ): 

103 self._resource = resource 

104 self._close_fn = ( 

105 close_fn or getattr(resource, "close", None) or getattr(resource, "aclose", None) 

106 ) 

107 self.info = ResourceInfo(name=name, resource_type=resource_type) 

108 self.info.acquired_at = time.monotonic() 

109 self.info.state = ResourceState.ACTIVE 

110 

111 @property 

112 def raw(self) -> Any: 

113 """Access the underlying resource object.""" 

114 return self._resource 

115 

116 async def close(self) -> None: 

117 if self.info.state == ResourceState.CLOSED: 

118 return 

119 

120 self.info.state = ResourceState.CLOSING 

121 try: 

122 if self._close_fn is not None: 

123 result = self._close_fn() 

124 if asyncio.iscoroutine(result): 

125 await result 

126 except Exception as exc: 

127 self.info.state = ResourceState.ERROR 

128 logger.error("Failed to close resource %s: %s", self.info.name, exc) 

129 raise 

130 else: 

131 self.info.state = ResourceState.CLOSED 

132 self.info.released_at = time.monotonic() 

133 

134 async def health_check(self) -> bool: 

135 if self.info.state == ResourceState.CLOSED: 

136 return False 

137 health_fn = getattr(self._resource, "health_check", None) or getattr( 

138 self._resource, "ping", None 

139 ) 

140 if health_fn is not None: 

141 try: 

142 result = health_fn() 

143 if asyncio.iscoroutine(result): 

144 result = await result 

145 return bool(result) 

146 except Exception: 

147 return False 

148 return True 

149 

150 

151# ============================================================================ 

152# Resource Pool 

153# ============================================================================ 

154 

155 

156class ResourcePool(AbstractResource): 

157 """Generic async resource pool with health-checked lend/return. 

158 

159 Usage: 

160 pool = ResourcePool(factory=create_db_conn, max_size=10) 

161 async with pool.acquire() as conn: 

162 await conn.query(...) 

163 """ 

164 

165 def __init__( 

166 self, 

167 factory: Callable[[], Any], 

168 max_size: int = 10, 

169 min_size: int = 0, 

170 idle_timeout: float = 300.0, 

171 health_check_interval: float = 30.0, 

172 name: str = "pool", 

173 ): 

174 self._factory = factory 

175 self._max_size = max_size 

176 self._min_size = min_size 

177 self._idle_timeout = idle_timeout 

178 self._health_check_interval = health_check_interval 

179 self._name = name 

180 

181 self._available: asyncio.Queue = asyncio.Queue(maxsize=max_size) 

182 self._in_use: dict[int, Any] = {} # id(resource) → resource 

183 self._total: int = 0 

184 self._lock = asyncio.Lock() 

185 self._closed = False 

186 self._health_task: asyncio.Task | None = None 

187 

188 async def _prefill(self): 

189 """Pre-create min_size connections.""" 

190 for _ in range(self._min_size): 

191 resource = self._factory() 

192 if asyncio.iscoroutine(resource): 

193 resource = await resource 

194 await self._available.put(resource) 

195 self._total += 1 

196 

197 async def start(self): 

198 """Initialize the pool.""" 

199 await self._prefill() 

200 if self._health_check_interval > 0: 

201 self._health_task = asyncio.ensure_future(self._health_loop()) 

202 

203 @asynccontextmanager 

204 async def acquire(self) -> AsyncIterator[Any]: 

205 """Acquire a resource from the pool. Returns via context manager.""" 

206 resource = await self._get() 

207 try: 

208 yield resource 

209 finally: 

210 await self._return(resource) 

211 

212 async def _get(self) -> Any: 

213 if self._closed: 

214 raise RuntimeError(f"ResourcePool '{self._name}' is closed") 

215 

216 # Try to get from available 

217 try: 

218 resource = self._available.get_nowait() 

219 self._in_use[id(resource)] = resource 

220 return resource 

221 except asyncio.QueueEmpty: 

222 pass 

223 

224 # Try to create new 

225 async with self._lock: 

226 if self._total < self._max_size: 

227 resource = self._factory() 

228 if asyncio.iscoroutine(resource): 

229 resource = await resource 

230 self._total += 1 

231 self._in_use[id(resource)] = resource 

232 return resource 

233 

234 # Wait for one to become available 

235 resource = await self._available.get() 

236 self._in_use[id(resource)] = resource 

237 return resource 

238 

239 async def _return(self, resource: Any): 

240 self._in_use.pop(id(resource), None) 

241 if not self._closed: 

242 await self._available.put(resource) 

243 

244 async def _health_loop(self): 

245 """Periodic health check, remove dead connections.""" 

246 while not self._closed: 

247 await asyncio.sleep(self._health_check_interval) 

248 # Drain and re-check 

249 healthy: list[Any] = [] 

250 while not self._available.empty(): 

251 try: 

252 resource = self._available.get_nowait() 

253 if self._is_healthy(resource): 

254 healthy.append(resource) 

255 else: 

256 self._total -= 1 

257 logger.debug("Pool '%s': removed unhealthy connection", self._name) 

258 except asyncio.QueueEmpty: 

259 break 

260 

261 for resource in healthy: 

262 await self._available.put(resource) 

263 

264 # Top up to min_size 

265 while self._total < self._min_size and not self._closed: 

266 async with self._lock: 

267 if self._total < self._max_size: 

268 resource = self._factory() 

269 if asyncio.iscoroutine(resource): 

270 resource = await resource 

271 await self._available.put(resource) 

272 self._total += 1 

273 

274 def _is_healthy(self, resource: Any) -> bool: 

275 """Check if a resource is healthy.""" 

276 health_fn = getattr(resource, "health_check", None) or getattr(resource, "ping", None) 

277 if health_fn is None: 

278 return True 

279 try: 

280 result = health_fn() 

281 if asyncio.iscoroutine(result): 

282 return True # Can't check async in sync context 

283 return bool(result) 

284 except Exception: 

285 return False 

286 

287 def _close_resource(self, resource: Any, errors: list[Exception]): 

288 """Attempt to close a single resource, collecting errors.""" 

289 close_fn = ( 

290 getattr(resource, "close", None) 

291 or getattr(resource, "aclose", None) 

292 or (resource.get("close") if isinstance(resource, dict) else None) 

293 or (resource.get("aclose") if isinstance(resource, dict) else None) 

294 ) 

295 if close_fn is not None: 

296 try: 

297 result = close_fn() 

298 if asyncio.iscoroutine(result): 

299 asyncio.ensure_future(self._await_close(result, errors)) 

300 except Exception as exc: 

301 errors.append(exc) 

302 

303 @staticmethod 

304 async def _await_close(coro, errors: list[Exception]): 

305 try: 

306 await coro 

307 except Exception as exc: 

308 errors.append(exc) 

309 

310 async def close(self) -> None: 

311 """Close all resources in the pool.""" 

312 if self._closed: 

313 return 

314 self._closed = True 

315 

316 if self._health_task: 

317 self._health_task.cancel() 

318 try: 

319 await self._health_task 

320 except asyncio.CancelledError: 

321 pass 

322 

323 # Close all resources 

324 errors: list[Exception] = [] 

325 

326 # Close in-use 

327 for rid, resource in list(self._in_use.items()): 

328 self._close_resource(resource, errors) 

329 self._in_use.clear() 

330 

331 # Close available 

332 while not self._available.empty(): 

333 try: 

334 resource = self._available.get_nowait() 

335 self._close_resource(resource, errors) 

336 except asyncio.QueueEmpty: 

337 break 

338 except Exception as exc: 

339 errors.append(exc) 

340 

341 if errors: 

342 logger.error("Pool '%s': %d errors during close", self._name, len(errors)) 

343 

344 async def health_check(self) -> bool: 

345 return not self._closed 

346 

347 @property 

348 def stats(self) -> dict[str, Any]: 

349 """Pool statistics.""" 

350 return { 

351 "name": self._name, 

352 "total": self._total, 

353 "in_use": len(self._in_use), 

354 "available": self._available.qsize(), 

355 "max_size": self._max_size, 

356 "closed": self._closed, 

357 } 

358 

359 

360# ============================================================================ 

361# Resource Manager (central registry) 

362# ============================================================================ 

363 

364 

365class ResourceManager: 

366 """Central resource lifecycle manager. 

367 

368 Tracks all resources, ensures ordered shutdown (LIFO), 

369 provides leak detection. 

370 

371 Usage: 

372 rm = ResourceManager() 

373 conn = await rm.register("db", managed_pg_conn) 

374 # ... use conn ... 

375 await rm.shutdown() # closes all in reverse order 

376 """ 

377 

378 def __init__(self, leak_warn_threshold: float = 300.0): 

379 self._resources: list[tuple[str, AbstractResource]] = [] 

380 self._lock = asyncio.Lock() 

381 self._leak_threshold = leak_warn_threshold 

382 self._shutting_down = False 

383 self._finalizers: list[Callable[[], Any]] = [] 

384 

385 async def register(self, name: str, resource: AbstractResource) -> AbstractResource: 

386 """Register a resource for lifecycle management.""" 

387 async with self._lock: 

388 if self._shutting_down: 

389 raise RuntimeError( 

390 "ResourceManager is shutting down — cannot register new resources" 

391 ) 

392 self._resources.append((name, resource)) 

393 return resource 

394 

395 async def unregister(self, name: str) -> AbstractResource | None: 

396 """Remove a resource from management (without closing).""" 

397 async with self._lock: 

398 for i, (n, r) in enumerate(self._resources): 

399 if n == name: 

400 self._resources.pop(i) 

401 return r 

402 return None 

403 

404 def add_finalizer(self, fn: Callable[[], Any]): 

405 """Register a finalizer to run during shutdown.""" 

406 self._finalizers.append(fn) 

407 

408 async def get(self, name: str) -> AbstractResource | None: 

409 """Find a managed resource by name.""" 

410 for n, r in self._resources: 

411 if n == name: 

412 return r 

413 return None 

414 

415 async def shutdown(self, timeout: float = 30.0) -> list[str]: 

416 """Ordered shutdown — LIFO order, with timeout per resource. 

417 

418 Returns list of resource names that failed to close. 

419 """ 

420 self._shutting_down = True 

421 failures: list[str] = [] 

422 

423 # Close resources in reverse order 

424 async with self._lock: 

425 resources = list(reversed(self._resources)) 

426 self._resources.clear() 

427 

428 for name, resource in resources: 

429 try: 

430 await asyncio.wait_for(resource.close(), timeout=timeout) 

431 logger.debug("Closed resource: %s", name) 

432 except TimeoutError: 

433 failures.append(f"{name} (timeout)") 

434 logger.error("Resource '%s' close timed out after %.0fs", name, timeout) 

435 except Exception as exc: 

436 failures.append(f"{name} ({exc})") 

437 logger.error("Failed to close resource '%s': %s", name, exc) 

438 

439 # Run finalizers 

440 for fn in self._finalizers: 

441 try: 

442 result = fn() 

443 if asyncio.iscoroutine(result): 

444 await result 

445 except Exception as exc: 

446 logger.error("Finalizer failed: %s", exc) 

447 

448 return failures 

449 

450 async def health_report(self) -> dict[str, bool]: 

451 """Health check all registered resources.""" 

452 report: dict[str, bool] = {} 

453 async with self._lock: 

454 for name, resource in self._resources: 

455 try: 

456 report[name] = await resource.health_check() 

457 except Exception: 

458 report[name] = False 

459 return report 

460 

461 def check_leaks(self) -> list[str]: 

462 """Check for resources held beyond leak threshold.""" 

463 now = time.monotonic() 

464 leaks: list[str] = [] 

465 for name, resource in self._resources: 

466 if hasattr(resource, "info") and hasattr(resource.info, "acquired_at"): 

467 age = now - resource.info.acquired_at 

468 if age > self._leak_threshold and resource.info.state != ResourceState.CLOSED: 

469 leaks.append(f"{name} (held {age:.0f}s)") 

470 return leaks 

471 

472 @property 

473 def size(self) -> int: 

474 return len(self._resources) 

475 

476 

477# ============================================================================ 

478# Global singleton 

479# ============================================================================ 

480 

481_global_resource_manager: ResourceManager | None = None 

482 

483 

484def get_resource_manager() -> ResourceManager: 

485 """Get or create the global ResourceManager singleton.""" 

486 global _global_resource_manager 

487 if _global_resource_manager is None: 

488 _global_resource_manager = ResourceManager() 

489 return _global_resource_manager