Coverage for core / platform / registry.py: 97.5%

163 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Service Registry — Typed, lazy-loaded, thread-safe service container. 

3 

4Replaces the ad-hoc `_instance = None` + `get_*()` singleton pattern used 

5in 6+ modules across the codebase with a unified registry. 

6 

7Design decisions: 

8- String-named services (not type-keyed) — allows swapping implementations 

9- Lazy instantiation — factory not called until first get() 

10- Singleton by default — one instance per name; factory mode available 

11- Optional Lifecycle protocol — start()/stop()/health() for managed services 

12- Dependency ordering — ensures services start in correct order 

13- Thread-safe — all mutations under threading.Lock 

14 

15Generalizes patterns from: 

16- circuit_breaker.py (threading.Lock, state machine) 

17- service_manager.py (EngineService lifecycle, ServiceManager coordinator) 

18- Module-level singletons across agent_engine/ 

19 

20Usage: 

21 registry = get_registry() 

22 registry.register('theme', ThemeService) 

23 registry.register('events', EventBus, singleton=True) 

24 registry.register('compute', ComputeService, depends_on=['events']) 

25 

26 theme = registry.get('theme') # instantiates on first call 

27 registry.start_all() # calls .start() in dependency order 

28 registry.health() # -> {name: {status, uptime, error}} 

29 registry.stop_all() # calls .stop() in reverse order 

30""" 

31 

32import logging 

33import threading 

34import time 

35from typing import Any, Callable, Dict, List, Optional, Set 

36 

37logger = logging.getLogger('hevolve.platform') 

38 

39 

40# ═══════════════════════════════════════════════════════════════ 

41# Lifecycle Protocol 

42# ═══════════════════════════════════════════════════════════════ 

43 

44class Lifecycle: 

45 """Optional protocol for services that need managed start/stop. 

46 

47 Services that implement start()/stop()/health() get lifecycle 

48 management from the registry. Plain objects work fine too — 

49 they just don't participate in start_all()/stop_all(). 

50 """ 

51 

52 def start(self) -> None: 

53 """Called during registry.start_all() in dependency order.""" 

54 pass 

55 

56 def stop(self) -> None: 

57 """Called during registry.stop_all() in reverse dependency order.""" 

58 pass 

59 

60 def health(self) -> dict: 

61 """Return health status. Default: always healthy.""" 

62 return {'status': 'ok'} 

63 

64 

65# ═══════════════════════════════════════════════════════════════ 

66# Service Entry (internal) 

67# ═══════════════════════════════════════════════════════════════ 

68 

69class _ServiceEntry: 

70 """Internal record for a registered service.""" 

71 

72 __slots__ = ('name', 'factory', 'singleton', 'depends_on', 

73 'instance', 'started', 'started_at', 'error') 

74 

75 def __init__(self, name: str, factory: Callable, singleton: bool, 

76 depends_on: List[str]): 

77 self.name = name 

78 self.factory = factory 

79 self.singleton = singleton 

80 self.depends_on = list(depends_on) 

81 self.instance: Any = None 

82 self.started: bool = False 

83 self.started_at: Optional[float] = None 

84 self.error: Optional[str] = None 

85 

86 

87# ═══════════════════════════════════════════════════════════════ 

88# Service Registry 

89# ═══════════════════════════════════════════════════════════════ 

90 

91class ServiceRegistry: 

92 """Typed, lazy-loaded, thread-safe service container. 

93 

94 The central nervous system of HART OS — every subsystem registers 

95 its services here, and any subsystem can look up any other. 

96 """ 

97 

98 def __init__(self): 

99 self._entries: Dict[str, _ServiceEntry] = {} 

100 self._lock = threading.RLock() 

101 self._start_order: List[str] = [] # tracks actual start order for stop 

102 

103 def register(self, name: str, factory: Callable, *, 

104 singleton: bool = True, 

105 depends_on: Optional[List[str]] = None) -> None: 

106 """Register a service factory. 

107 

108 Args: 

109 name: Unique service name (e.g., 'theme', 'events', 'compute') 

110 factory: Any callable that returns the service instance. 

111 Can be a class, function, or lambda. 

112 singleton: If True (default), factory called once and cached. 

113 If False, factory called on every get(). 

114 depends_on: List of service names that must be started before 

115 this one. Only affects start_all() ordering. 

116 

117 Raises: 

118 ValueError: If name is already registered. 

119 """ 

120 with self._lock: 

121 if name in self._entries: 

122 raise ValueError(f"Service '{name}' already registered") 

123 self._entries[name] = _ServiceEntry( 

124 name=name, 

125 factory=factory, 

126 singleton=singleton, 

127 depends_on=depends_on or [], 

128 ) 

129 

130 def unregister(self, name: str) -> None: 

131 """Remove a service. Stops it first if running. 

132 

133 Args: 

134 name: Service name to remove. 

135 

136 Raises: 

137 KeyError: If name not registered. 

138 """ 

139 with self._lock: 

140 if name not in self._entries: 

141 raise KeyError(f"Service '{name}' not registered") 

142 entry = self._entries[name] 

143 if entry.started and entry.instance and hasattr(entry.instance, 'stop'): 

144 try: 

145 entry.instance.stop() 

146 except Exception as e: 

147 logger.warning("Error stopping service '%s': %s", name, e) 

148 if name in self._start_order: 

149 self._start_order.remove(name) 

150 del self._entries[name] 

151 

152 def get(self, name: str) -> Any: 

153 """Get a service instance. Instantiates lazily on first call. 

154 

155 Args: 

156 name: Service name. 

157 

158 Returns: 

159 The service instance. 

160 

161 Raises: 

162 KeyError: If name not registered. 

163 RuntimeError: If factory raises during instantiation. 

164 """ 

165 with self._lock: 

166 if name not in self._entries: 

167 raise KeyError(f"Service '{name}' not registered") 

168 entry = self._entries[name] 

169 

170 # Non-singleton: always create new 

171 if not entry.singleton: 

172 try: 

173 return entry.factory() 

174 except Exception as e: 

175 logger.error("Factory failed for '%s': %s", name, e) 

176 raise RuntimeError( 

177 f"Failed to create service '{name}': {e}") from e 

178 

179 # Singleton: return cached or create 

180 if entry.instance is not None: 

181 return entry.instance 

182 

183 try: 

184 entry.instance = entry.factory() 

185 return entry.instance 

186 except Exception as e: 

187 entry.error = str(e) 

188 logger.error("Factory failed for '%s': %s", name, e) 

189 raise RuntimeError( 

190 f"Failed to create service '{name}': {e}") from e 

191 

192 def has(self, name: str) -> bool: 

193 """Check if a service is registered.""" 

194 return name in self._entries 

195 

196 def names(self) -> List[str]: 

197 """Return all registered service names.""" 

198 return list(self._entries.keys()) 

199 

200 # ── Lifecycle Management ────────────────────────────────── 

201 

202 def start_all(self) -> None: 

203 """Start all lifecycle-aware services in dependency order. 

204 

205 Services without start() are skipped. Dependencies are resolved 

206 via topological sort — if A depends_on B, B starts first. 

207 """ 

208 order = self._resolve_start_order() 

209 for name in order: 

210 self._start_service(name) 

211 

212 def stop_all(self) -> None: 

213 """Stop all started services in reverse start order.""" 

214 for name in reversed(list(self._start_order)): 

215 self._stop_service(name) 

216 

217 def health(self) -> Dict[str, dict]: 

218 """Return health status for all registered services. 

219 

220 Returns: 

221 Dict mapping service name to health dict. 

222 Non-lifecycle services report {'status': 'registered'}. 

223 """ 

224 result = {} 

225 with self._lock: 

226 for name, entry in self._entries.items(): 

227 if entry.error: 

228 result[name] = {'status': 'error', 'error': entry.error} 

229 elif entry.instance is None: 

230 result[name] = {'status': 'not_instantiated'} 

231 elif not entry.started: 

232 result[name] = {'status': 'instantiated'} 

233 elif hasattr(entry.instance, 'health'): 

234 try: 

235 h = entry.instance.health() 

236 if entry.started_at: 

237 h['uptime_seconds'] = int( 

238 time.time() - entry.started_at) 

239 result[name] = h 

240 except Exception as e: 

241 result[name] = {'status': 'error', 'error': str(e)} 

242 else: 

243 info = {'status': 'running'} 

244 if entry.started_at: 

245 info['uptime_seconds'] = int( 

246 time.time() - entry.started_at) 

247 result[name] = info 

248 return result 

249 

250 def reset(self) -> None: 

251 """Stop all services and clear the registry. For testing.""" 

252 self.stop_all() 

253 with self._lock: 

254 self._entries.clear() 

255 self._start_order.clear() 

256 

257 # ── Internal ────────────────────────────────────────────── 

258 

259 def _start_service(self, name: str) -> None: 

260 """Start a single service if it has a start() method.""" 

261 with self._lock: 

262 entry = self._entries.get(name) 

263 if not entry or entry.started: 

264 return 

265 

266 # Ensure instance exists 

267 if entry.instance is None and entry.singleton: 

268 try: 

269 entry.instance = entry.factory() 

270 except Exception as e: 

271 entry.error = str(e) 

272 logger.error("Factory failed for '%s': %s", name, e) 

273 return 

274 

275 instance = entry.instance 

276 if instance and hasattr(instance, 'start'): 

277 try: 

278 instance.start() 

279 entry.started = True 

280 entry.started_at = time.time() 

281 entry.error = None 

282 self._start_order.append(name) 

283 logger.debug("Started service '%s'", name) 

284 except Exception as e: 

285 entry.error = str(e) 

286 logger.error("Failed to start '%s': %s", name, e) 

287 else: 

288 # No start() — mark as started anyway for tracking 

289 entry.started = True 

290 entry.started_at = time.time() 

291 self._start_order.append(name) 

292 

293 def _stop_service(self, name: str) -> None: 

294 """Stop a single service if it has a stop() method.""" 

295 with self._lock: 

296 entry = self._entries.get(name) 

297 if not entry or not entry.started: 

298 return 

299 

300 if entry.instance and hasattr(entry.instance, 'stop'): 

301 try: 

302 entry.instance.stop() 

303 logger.debug("Stopped service '%s'", name) 

304 except Exception as e: 

305 logger.warning("Error stopping '%s': %s", name, e) 

306 

307 entry.started = False 

308 entry.started_at = None 

309 if name in self._start_order: 

310 self._start_order.remove(name) 

311 

312 def _resolve_start_order(self) -> List[str]: 

313 """Topological sort of services by depends_on. 

314 

315 Returns list of service names in safe start order. 

316 Raises ValueError on circular dependencies. 

317 """ 

318 with self._lock: 

319 # Kahn's algorithm 

320 in_degree: Dict[str, int] = {} 

321 graph: Dict[str, List[str]] = {} 

322 

323 for name, entry in self._entries.items(): 

324 in_degree.setdefault(name, 0) 

325 graph.setdefault(name, []) 

326 for dep in entry.depends_on: 

327 if dep in self._entries: 

328 graph.setdefault(dep, []).append(name) 

329 in_degree[name] = in_degree.get(name, 0) + 1 

330 

331 queue = [n for n, d in in_degree.items() if d == 0] 

332 order: List[str] = [] 

333 

334 while queue: 

335 node = queue.pop(0) 

336 order.append(node) 

337 for neighbor in graph.get(node, []): 

338 in_degree[neighbor] -= 1 

339 if in_degree[neighbor] == 0: 

340 queue.append(neighbor) 

341 

342 if len(order) != len(self._entries): 

343 missing = set(self._entries.keys()) - set(order) 

344 raise ValueError( 

345 f"Circular dependency detected among: {missing}") 

346 

347 return order 

348 

349 

350# ═══════════════════════════════════════════════════════════════ 

351# Global Singleton 

352# ═══════════════════════════════════════════════════════════════ 

353 

354_registry: Optional[ServiceRegistry] = None 

355_registry_lock = threading.Lock() 

356 

357 

358def get_registry() -> ServiceRegistry: 

359 """Get the global ServiceRegistry singleton. 

360 

361 Thread-safe. Creates the registry on first call. 

362 """ 

363 global _registry 

364 if _registry is not None: 

365 return _registry 

366 with _registry_lock: 

367 if _registry is None: 

368 _registry = ServiceRegistry() 

369 return _registry 

370 

371 

372def reset_registry() -> None: 

373 """Reset the global registry. For testing only.""" 

374 global _registry 

375 with _registry_lock: 

376 if _registry: 

377 _registry.reset() 

378 _registry = None