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
« 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.
4Replaces the ad-hoc `_instance = None` + `get_*()` singleton pattern used
5in 6+ modules across the codebase with a unified registry.
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
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/
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'])
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"""
32import logging
33import threading
34import time
35from typing import Any, Callable, Dict, List, Optional, Set
37logger = logging.getLogger('hevolve.platform')
40# ═══════════════════════════════════════════════════════════════
41# Lifecycle Protocol
42# ═══════════════════════════════════════════════════════════════
44class Lifecycle:
45 """Optional protocol for services that need managed start/stop.
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 """
52 def start(self) -> None:
53 """Called during registry.start_all() in dependency order."""
54 pass
56 def stop(self) -> None:
57 """Called during registry.stop_all() in reverse dependency order."""
58 pass
60 def health(self) -> dict:
61 """Return health status. Default: always healthy."""
62 return {'status': 'ok'}
65# ═══════════════════════════════════════════════════════════════
66# Service Entry (internal)
67# ═══════════════════════════════════════════════════════════════
69class _ServiceEntry:
70 """Internal record for a registered service."""
72 __slots__ = ('name', 'factory', 'singleton', 'depends_on',
73 'instance', 'started', 'started_at', 'error')
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
87# ═══════════════════════════════════════════════════════════════
88# Service Registry
89# ═══════════════════════════════════════════════════════════════
91class ServiceRegistry:
92 """Typed, lazy-loaded, thread-safe service container.
94 The central nervous system of HART OS — every subsystem registers
95 its services here, and any subsystem can look up any other.
96 """
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
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.
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.
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 )
130 def unregister(self, name: str) -> None:
131 """Remove a service. Stops it first if running.
133 Args:
134 name: Service name to remove.
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]
152 def get(self, name: str) -> Any:
153 """Get a service instance. Instantiates lazily on first call.
155 Args:
156 name: Service name.
158 Returns:
159 The service instance.
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]
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
179 # Singleton: return cached or create
180 if entry.instance is not None:
181 return entry.instance
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
192 def has(self, name: str) -> bool:
193 """Check if a service is registered."""
194 return name in self._entries
196 def names(self) -> List[str]:
197 """Return all registered service names."""
198 return list(self._entries.keys())
200 # ── Lifecycle Management ──────────────────────────────────
202 def start_all(self) -> None:
203 """Start all lifecycle-aware services in dependency order.
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)
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)
217 def health(self) -> Dict[str, dict]:
218 """Return health status for all registered services.
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
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()
257 # ── Internal ──────────────────────────────────────────────
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
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
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)
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
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)
307 entry.started = False
308 entry.started_at = None
309 if name in self._start_order:
310 self._start_order.remove(name)
312 def _resolve_start_order(self) -> List[str]:
313 """Topological sort of services by depends_on.
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]] = {}
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
331 queue = [n for n, d in in_degree.items() if d == 0]
332 order: List[str] = []
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)
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}")
347 return order
350# ═══════════════════════════════════════════════════════════════
351# Global Singleton
352# ═══════════════════════════════════════════════════════════════
354_registry: Optional[ServiceRegistry] = None
355_registry_lock = threading.Lock()
358def get_registry() -> ServiceRegistry:
359 """Get the global ServiceRegistry singleton.
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
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