Coverage for agentos/core/lifecycle.py: 99%

169 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 11:37 +0800

1"""AgentOS Lifecycle — graceful startup/shutdown with ordered hooks. 

2 

3Provides enterprise-grade process lifecycle management: 

4- Ordered startup hooks with timeout and health gate 

5- Ordered shutdown hooks with grace period 

6- SIGTERM/SIGINT graceful shutdown integration 

7- Component-level health registration 

8- Liveness/readiness probe support 

9 

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

11""" 

12 

13from __future__ import annotations 

14 

15import asyncio 

16import logging 

17import signal 

18import time 

19from dataclasses import dataclass, field 

20from enum import Enum 

21from typing import Any, Callable, Dict, List, Optional 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26# ============================================================================ 

27# Types 

28# ============================================================================ 

29 

30class LifecyclePhase(str, Enum): 

31 """Ordered lifecycle phases during startup.""" 

32 

33 CONFIG = "config" # Configuration loading 

34 INFRA = "infra" # DB, Redis, message queues 

35 SECURITY = "security" # Auth, encryption, certs 

36 SERVICES = "services" # Internal services 

37 MIDDLEWARE = "middleware" # Middleware pipeline 

38 API = "api" # HTTP/gRPC server 

39 READY = "ready" # Final readiness signal 

40 

41 

42class ComponentStatus(str, Enum): 

43 """Component health status.""" 

44 

45 UNINITIALIZED = "uninitialized" 

46 STARTING = "starting" 

47 HEALTHY = "healthy" 

48 DEGRADED = "degraded" 

49 UNHEALTHY = "unhealthy" 

50 SHUTTING_DOWN = "shutting_down" 

51 STOPPED = "stopped" 

52 

53 

54@dataclass 

55class LifecycleHook: 

56 """A single startup or shutdown hook with metadata.""" 

57 

58 name: str 

59 phase: LifecyclePhase 

60 fn: Callable[[], Any] # or async callable 

61 timeout_seconds: float = 30.0 

62 is_async: bool = False 

63 critical: bool = True # Fail startup if critical hook fails 

64 weight: int = 50 # Ordering within same phase (lower = first) 

65 retries: int = 0 

66 retry_delay: float = 1.0 

67 

68 

69@dataclass 

70class ComponentHealth: 

71 """Health status of a single component.""" 

72 

73 name: str 

74 status: ComponentStatus = ComponentStatus.UNINITIALIZED 

75 phase: Optional[LifecyclePhase] = None 

76 message: str = "" 

77 error: Optional[str] = None 

78 started_at: Optional[float] = None 

79 duration_ms: float = 0.0 

80 

81 

82@dataclass 

83class LifecycleReport: 

84 """Full lifecycle status report.""" 

85 

86 overall_status: ComponentStatus = ComponentStatus.UNINITIALIZED 

87 phase: str = "" 

88 components: Dict[str, ComponentHealth] = field(default_factory=dict) 

89 startup_duration_ms: float = 0.0 

90 shutdown_remaining_hooks: int = 0 

91 

92 @property 

93 def is_healthy(self) -> bool: 

94 return self.overall_status == ComponentStatus.HEALTHY 

95 

96 @property 

97 def is_ready(self) -> bool: 

98 return self.overall_status in ( 

99 ComponentStatus.HEALTHY, ComponentStatus.DEGRADED 

100 ) 

101 

102 

103# ============================================================================ 

104# Lifecycle Manager 

105# ============================================================================ 

106 

107class LifecycleManager: 

108 """Orchestrates ordered startup and graceful shutdown. 

109 

110 Usage: 

111 lm = LifecycleManager() 

112 

113 @lm.on_startup(phase=LifecyclePhase.INFRA) 

114 async def init_db(): 

115 ... 

116 

117 @lm.on_shutdown 

118 async def close_db(): 

119 ... 

120 

121 async with lm: 

122 # Application runs here 

123 ... 

124 

125 Signal handling (SIGTERM, SIGINT) integrated automatically. 

126 """ 

127 

128 PHASE_ORDER: List[LifecyclePhase] = [ 

129 LifecyclePhase.CONFIG, 

130 LifecyclePhase.INFRA, 

131 LifecyclePhase.SECURITY, 

132 LifecyclePhase.SERVICES, 

133 LifecyclePhase.MIDDLEWARE, 

134 LifecyclePhase.API, 

135 LifecyclePhase.READY, 

136 ] 

137 

138 def __init__( 

139 self, 

140 grace_period: float = 30.0, 

141 startup_timeout: float = 120.0, 

142 ): 

143 self._startup_hooks: List[LifecycleHook] = [] 

144 self._shutdown_hooks: List[LifecycleHook] = [] 

145 self._health: Dict[str, ComponentHealth] = {} 

146 self._status = ComponentStatus.UNINITIALIZED 

147 self._start_time: Optional[float] = None 

148 self._grace_period = grace_period 

149 self._startup_timeout = startup_timeout 

150 self._shutdown_event = asyncio.Event() 

151 self._ready_event = asyncio.Event() 

152 

153 # ── Registration ────────────────────────────────────────────────────── 

154 

155 def on_startup( 

156 self, 

157 name: Optional[str] = None, 

158 *, 

159 phase: LifecyclePhase = LifecyclePhase.SERVICES, 

160 critical: bool = True, 

161 timeout_seconds: float = 30.0, 

162 weight: int = 50, 

163 retries: int = 0, 

164 retry_delay: float = 1.0, 

165 ) -> Callable: 

166 """Decorator: register a startup hook.""" 

167 def decorator(fn): 

168 hook_name = name or fn.__name__ 

169 is_async = asyncio.iscoroutinefunction(fn) 

170 self._startup_hooks.append(LifecycleHook( 

171 name=hook_name, 

172 phase=phase, 

173 fn=fn, 

174 timeout_seconds=timeout_seconds, 

175 is_async=is_async, 

176 critical=critical, 

177 weight=weight, 

178 retries=retries, 

179 retry_delay=retry_delay, 

180 )) 

181 self._health[hook_name] = ComponentHealth( 

182 name=hook_name, phase=phase 

183 ) 

184 return fn 

185 return decorator 

186 

187 def on_shutdown( 

188 self, 

189 name: Optional[str] = None, 

190 *, 

191 timeout_seconds: float = 10.0, 

192 weight: int = 50, 

193 ) -> Callable: 

194 """Decorator: register a shutdown hook (reverse order).""" 

195 def decorator(fn): 

196 hook_name = name or fn.__name__ 

197 is_async = asyncio.iscoroutinefunction(fn) 

198 self._shutdown_hooks.append(LifecycleHook( 

199 name=hook_name, 

200 phase=LifecyclePhase.READY, # irrelevant for shutdown 

201 fn=fn, 

202 timeout_seconds=timeout_seconds, 

203 is_async=is_async, 

204 critical=False, 

205 weight=weight, 

206 )) 

207 return fn 

208 return decorator 

209 

210 # ── Startup ─────────────────────────────────────────────────────────── 

211 

212 async def start(self) -> LifecycleReport: 

213 """Execute all startup hooks in phase/weight order.""" 

214 self._start_time = time.perf_counter() 

215 self._status = ComponentStatus.STARTING 

216 

217 # Sort: phase order first, then weight within phase 

218 phase_idx = {p: i for i, p in enumerate(self.PHASE_ORDER)} 

219 sorted_hooks = sorted( 

220 self._startup_hooks, 

221 key=lambda h: (phase_idx.get(h.phase, 99), h.weight), 

222 ) 

223 

224 for hook in sorted_hooks: 

225 ok = await self._execute_hook(hook, is_startup=True) 

226 if not ok and hook.critical: 

227 self._status = ComponentStatus.UNHEALTHY 

228 return self.report() 

229 

230 self._status = ComponentStatus.HEALTHY 

231 self._ready_event.set() 

232 

233 return self.report() 

234 

235 async def _execute_hook( 

236 self, hook: LifecycleHook, is_startup: bool = True 

237 ) -> bool: 

238 """Execute a single hook with timeout, retries, and health tracking.""" 

239 health = self._health.get(hook.name) or ComponentHealth(name=hook.name) 

240 health.status = ComponentStatus.STARTING if is_startup else ComponentStatus.SHUTTING_DOWN 

241 health.started_at = time.time() 

242 

243 attempt = 0 

244 last_error = None 

245 

246 while attempt <= hook.retries: 

247 t0 = time.perf_counter() 

248 try: 

249 if hook.is_async: 

250 await asyncio.wait_for(hook.fn(), timeout=hook.timeout_seconds) 

251 else: 

252 loop = asyncio.get_event_loop() 

253 await asyncio.wait_for( 

254 loop.run_in_executor(None, hook.fn), 

255 timeout=hook.timeout_seconds, 

256 ) 

257 health.duration_ms = (time.perf_counter() - t0) * 1000 

258 health.status = ComponentStatus.HEALTHY if is_startup else ComponentStatus.STOPPED 

259 health.message = "OK" 

260 logger.info(f"[lifecycle] {hook.name}: OK ({health.duration_ms:.0f}ms)") 

261 return True 

262 

263 except asyncio.TimeoutError: 

264 last_error = f"Timeout after {hook.timeout_seconds}s" 

265 health.error = last_error 

266 logger.warning(f"[lifecycle] {hook.name}: {last_error}") 

267 except Exception as e: 

268 last_error = str(e) 

269 health.error = last_error 

270 logger.warning(f"[lifecycle] {hook.name}: {last_error}") 

271 

272 attempt += 1 

273 if attempt <= hook.retries: 

274 await asyncio.sleep(hook.retry_delay) 

275 

276 health.status = ComponentStatus.UNHEALTHY 

277 health.duration_ms = (time.perf_counter() - t0) * 1000 

278 return False 

279 

280 # ── Shutdown ────────────────────────────────────────────────────────── 

281 

282 async def shutdown(self, signal_name: str = "") -> LifecycleReport: 

283 """Execute all shutdown hooks in reverse registration order.""" 

284 if self._status == ComponentStatus.STOPPED: 

285 return self.report() 

286 

287 self._status = ComponentStatus.SHUTTING_DOWN 

288 self._shutdown_event.set() 

289 logger.info(f"[lifecycle] Shutting down gracefully{f' ({signal_name})' if signal_name else ''}") 

290 

291 # Reverse order for shutdown (LIFO — last started, first stopped) 

292 for hook in reversed(self._shutdown_hooks): 

293 await self._execute_hook(hook, is_startup=False) 

294 

295 self._status = ComponentStatus.STOPPED 

296 return self.report() 

297 

298 # ── Signal integration ──────────────────────────────────────────────── 

299 

300 def setup_signal_handlers(self, loop: Optional[asyncio.AbstractEventLoop] = None): 

301 """Register SIGTERM/SIGINT handlers on the event loop.""" 

302 if loop is None: 

303 loop = asyncio.get_event_loop() 

304 

305 for sig in (signal.SIGTERM, signal.SIGINT): 

306 try: 

307 loop.add_signal_handler( 

308 sig, 

309 lambda s=sig: asyncio.ensure_future( 

310 self.shutdown(signal.Signals(s).name) 

311 ), 

312 ) 

313 except (NotImplementedError, RuntimeError): 

314 # Windows or non-main-thread — fallback to signal.signal 

315 signal.signal(sig, lambda s, f: asyncio.ensure_future( 

316 self.shutdown(signal.Signals(s).name) 

317 )) 

318 

319 # ── Probes ──────────────────────────────────────────────────────────── 

320 

321 def is_ready(self) -> bool: 

322 """Readiness probe: is the service ready to accept requests?""" 

323 return self._ready_event.is_set() 

324 

325 def is_live(self) -> bool: 

326 """Liveness probe: is the process alive (not hung)?""" 

327 return self._status not in (ComponentStatus.STOPPED, ComponentStatus.UNHEALTHY) 

328 

329 # ── Report ──────────────────────────────────────────────────────────── 

330 

331 def report(self) -> LifecycleReport: 

332 """Generate a full lifecycle status report.""" 

333 startup_ms = 0.0 

334 if self._start_time: 

335 startup_ms = (time.perf_counter() - self._start_time) * 1000 

336 

337 return LifecycleReport( 

338 overall_status=self._status, 

339 phase=self._status.value, 

340 components=dict(self._health), 

341 startup_duration_ms=startup_ms, 

342 shutdown_remaining_hooks=len([ 

343 h for h in self._shutdown_hooks 

344 if self._health.get(h.name, ComponentHealth(name=h.name)).status 

345 not in (ComponentStatus.STOPPED,) 

346 ]), 

347 ) 

348 

349 # ── Context manager ─────────────────────────────────────────────────── 

350 

351 async def __aenter__(self): 

352 """Async context manager: start lifecycle.""" 

353 self.setup_signal_handlers() 

354 await self.start() 

355 return self 

356 

357 async def __aexit__(self, exc_type, exc_val, exc_tb): 

358 """Async context manager: graceful shutdown.""" 

359 await self.shutdown() 

360 return False # Don't suppress exceptions 

361 

362 

363# ============================================================================ 

364# Singleton helper 

365# ============================================================================ 

366 

367_default_lifecycle: Optional[LifecycleManager] = None 

368 

369 

370def get_lifecycle( 

371 grace_period: float = 30.0, 

372 startup_timeout: float = 120.0, 

373) -> LifecycleManager: 

374 """Get or create the global LifecycleManager singleton.""" 

375 global _default_lifecycle 

376 if _default_lifecycle is None: 

377 _default_lifecycle = LifecycleManager( 

378 grace_period=grace_period, 

379 startup_timeout=startup_timeout, 

380 ) 

381 return _default_lifecycle