stygian_graph/application/registry.rs
1//! Service registry and dependency injection
2//!
3//! Provides a runtime registry for wiring together ports and adapters.
4//!
5//! Key features:
6//! - Thread-safe dynamic registration via `Arc<RwLock<…>>`
7//! - Builder pattern for ergonomic construction
8//! - `LazyLock`-based process-wide default registry singleton
9//! - Per-service health checks and availability status
10//!
11//! # Example
12//!
13//! ```no_run
14//! use stygian_graph::application::registry::ServiceRegistry;
15//! use stygian_graph::adapters::noop::NoopService;
16//! use std::sync::Arc;
17//!
18//! let registry = ServiceRegistry::builder()
19//! .register("noop", Arc::new(NoopService))
20//! .build();
21//!
22//! let svc = registry.get("noop");
23//! assert!(svc.is_some());
24//! ```
25
26use std::collections::HashMap;
27use std::sync::{Arc, LazyLock, RwLock};
28
29use serde::{Deserialize, Serialize};
30use tracing::{debug, warn};
31
32use crate::ports::ScrapingService;
33
34// ─── Availability status ──────────────────────────────────────────────────────
35
36/// Availability status of a registered service
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum ServiceStatus {
39 /// Service responded to its last health check successfully
40 Healthy,
41 /// Service is degraded but still processing requests
42 Degraded(String),
43 /// Service is unavailable
44 Unavailable(String),
45 /// Health check has not been run yet
46 Unknown,
47}
48
49impl ServiceStatus {
50 /// Returns `true` when [`ServiceStatus::Healthy`] or [`ServiceStatus::Degraded`]
51 #[must_use]
52 pub const fn is_available(&self) -> bool {
53 matches!(self, Self::Healthy | Self::Degraded(_))
54 }
55}
56
57// ─── Registry entry ───────────────────────────────────────────────────────────
58
59struct RegistryEntry {
60 service: Arc<dyn ScrapingService>,
61 status: ServiceStatus,
62}
63
64// ─── ServiceRegistry ─────────────────────────────────────────────────────────
65
66/// Thread-safe runtime registry for [`ScrapingService`] adapters.
67///
68/// Use [`ServiceRegistry::builder()`] for ergonomic setup, or call
69/// [`ServiceRegistry::register`] at runtime for dynamic registration.
70///
71/// # Thread safety
72///
73/// All mutations are guarded by an `RwLock`. Reads are non-exclusive.
74pub struct ServiceRegistry {
75 entries: Arc<RwLock<HashMap<String, RegistryEntry>>>,
76}
77
78// SAFETY: RwLock poisoning only occurs on panic; panics are unrecoverable for
79// this service so unwrap is correct here.
80#[allow(clippy::unwrap_used)]
81impl ServiceRegistry {
82 /// Create an empty registry.
83 ///
84 /// # Example
85 ///
86 /// ```
87 /// use stygian_graph::application::registry::ServiceRegistry;
88 ///
89 /// let r = ServiceRegistry::new();
90 /// assert!(r.get("anything").is_none());
91 /// ```
92 #[must_use]
93 pub fn new() -> Self {
94 Self {
95 entries: Arc::new(RwLock::new(HashMap::new())),
96 }
97 }
98
99 /// Return a new [`RegistryBuilder`] for ergonomic construction.
100 ///
101 /// # Example
102 ///
103 /// ```
104 /// use stygian_graph::application::registry::ServiceRegistry;
105 /// use stygian_graph::adapters::noop::NoopService;
106 /// use std::sync::Arc;
107 ///
108 /// let r = ServiceRegistry::builder()
109 /// .register("noop", Arc::new(NoopService))
110 /// .build();
111 ///
112 /// assert!(r.get("noop").is_some());
113 /// ```
114 #[must_use]
115 pub fn builder() -> RegistryBuilder {
116 RegistryBuilder::new()
117 }
118
119 /// Register (or replace) a service at runtime.
120 ///
121 /// The service's initial status is set to [`ServiceStatus::Unknown`].
122 ///
123 /// # Panics
124 ///
125 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
126 /// while holding the write lock). Treat this as unrecoverable.
127 ///
128 /// # Example
129 ///
130 /// ```
131 /// use stygian_graph::application::registry::ServiceRegistry;
132 /// use stygian_graph::adapters::noop::NoopService;
133 /// use std::sync::Arc;
134 ///
135 /// let r = ServiceRegistry::new();
136 /// r.register("noop".to_string(), Arc::new(NoopService));
137 /// assert!(r.get("noop").is_some());
138 /// ```
139 pub fn register(&self, name: String, service: Arc<dyn ScrapingService>) {
140 let entry = RegistryEntry {
141 service,
142 status: ServiceStatus::Unknown,
143 };
144 self.entries.write().unwrap().insert(name, entry);
145 }
146
147 /// Look up a service by name.
148 ///
149 /// Returns `None` if the service is not registered.
150 ///
151 /// # Panics
152 ///
153 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
154 /// while holding the read lock). Treat this as unrecoverable.
155 #[must_use]
156 pub fn get(&self, name: &str) -> Option<Arc<dyn ScrapingService>> {
157 self.entries
158 .read()
159 .unwrap()
160 .get(name)
161 .map(|e| Arc::clone(&e.service))
162 }
163
164 /// Return the current [`ServiceStatus`] for the named service.
165 ///
166 /// Returns `None` if no service is registered under that name.
167 ///
168 /// # Panics
169 ///
170 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
171 /// while holding the read lock). Treat this as unrecoverable.
172 #[must_use]
173 pub fn status(&self, name: &str) -> Option<ServiceStatus> {
174 self.entries
175 .read()
176 .unwrap()
177 .get(name)
178 .map(|e| e.status.clone())
179 }
180
181 /// List all registered service names.
182 ///
183 /// # Panics
184 ///
185 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
186 /// while holding the read lock). Treat this as unrecoverable.
187 ///
188 /// # Example
189 ///
190 /// ```
191 /// use stygian_graph::application::registry::ServiceRegistry;
192 /// use stygian_graph::adapters::noop::NoopService;
193 /// use std::sync::Arc;
194 ///
195 /// let r = ServiceRegistry::new();
196 /// r.register("a".to_string(), Arc::new(NoopService));
197 /// r.register("b".to_string(), Arc::new(NoopService));
198 /// let mut names = r.names();
199 /// names.sort();
200 /// assert_eq!(names, vec!["a", "b"]);
201 /// ```
202 #[must_use]
203 pub fn names(&self) -> Vec<String> {
204 self.entries.read().unwrap().keys().cloned().collect()
205 }
206
207 /// Remove a service from the registry.
208 ///
209 /// Returns `true` if a service was removed, `false` if it was not registered.
210 ///
211 /// # Panics
212 ///
213 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
214 /// while holding the write lock). Treat this as unrecoverable.
215 #[must_use]
216 pub fn deregister(&self, name: &str) -> bool {
217 self.entries.write().unwrap().remove(name).is_some()
218 }
219
220 /// Run a simple connectivity health check on all registered services.
221 ///
222 /// Each service's name is pinged by executing a [`crate::ports::ServiceInput`]
223 /// with a no-op URL. Results update the stored [`ServiceStatus`]. Returns a
224 /// snapshot map of `name → status` after the checks complete.
225 ///
226 /// # Panics
227 ///
228 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
229 /// while holding the lock). Treat this as unrecoverable.
230 #[allow(clippy::unused_async)]
231 pub async fn health_check_all(&self) -> HashMap<String, ServiceStatus> {
232 let entries_snapshot: Vec<(String, Arc<dyn ScrapingService>)> = {
233 let guard = self.entries.read().unwrap();
234 guard
235 .iter()
236 .map(|(k, v)| (k.clone(), Arc::clone(&v.service)))
237 .collect()
238 };
239
240 let mut results = HashMap::new();
241
242 for (name, svc) in entries_snapshot {
243 let status = Self::probe_service(svc);
244 debug!(service = %name, ?status, "health check");
245 {
246 let mut guard = self.entries.write().unwrap();
247 if let Some(entry) = guard.get_mut(&name) {
248 entry.status = status.clone();
249 }
250 }
251 results.insert(name, status);
252 }
253
254 results
255 }
256
257 /// Probe a single service by calling its `name()` method and marking it
258 /// healthy. If the service panics or its name is empty we mark it degraded.
259 #[allow(clippy::needless_pass_by_value)]
260 fn probe_service(svc: Arc<dyn ScrapingService>) -> ServiceStatus {
261 let name = svc.name();
262 if name.is_empty() {
263 warn!("Service returned empty name during health probe");
264 ServiceStatus::Degraded("empty service name".to_string())
265 } else {
266 ServiceStatus::Healthy
267 }
268 }
269
270 /// Update stored status for a named service directly.
271 ///
272 /// Useful for external health-check feedback (e.g., from readiness probes).
273 ///
274 /// # Panics
275 ///
276 /// Panics if the internal `RwLock` is poisoned (i.e. another thread panicked
277 /// while holding the write lock). Treat this as unrecoverable.
278 pub fn update_status(&self, name: &str, status: ServiceStatus) {
279 let mut guard = self.entries.write().unwrap();
280 if let Some(entry) = guard.get_mut(name) {
281 entry.status = status;
282 }
283 }
284}
285
286impl Default for ServiceRegistry {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292// ─── Builder ──────────────────────────────────────────────────────────────────
293
294/// Builder for constructing a [`ServiceRegistry`].
295///
296/// # Example
297///
298/// ```
299/// use stygian_graph::application::registry::ServiceRegistry;
300/// use stygian_graph::adapters::noop::NoopService;
301/// use std::sync::Arc;
302///
303/// let registry = ServiceRegistry::builder()
304/// .register("noop", Arc::new(NoopService))
305/// .build();
306///
307/// assert_eq!(registry.names().len(), 1);
308/// ```
309pub struct RegistryBuilder {
310 entries: HashMap<String, Arc<dyn ScrapingService>>,
311}
312
313#[allow(clippy::unwrap_used)] // RwLock poisoning is unrecoverable
314impl RegistryBuilder {
315 fn new() -> Self {
316 Self {
317 entries: HashMap::new(),
318 }
319 }
320
321 /// Register a service with the given name.
322 #[must_use]
323 pub fn register(mut self, name: impl Into<String>, service: Arc<dyn ScrapingService>) -> Self {
324 self.entries.insert(name.into(), service);
325 self
326 }
327
328 /// Build the registry from accumulated registrations.
329 ///
330 /// # Panics
331 ///
332 /// Panics if the registry's internal `RwLock` is poisoned (i.e. another thread
333 /// panicked while holding the write lock). Treat this as unrecoverable.
334 #[must_use]
335 pub fn build(self) -> ServiceRegistry {
336 let registry = ServiceRegistry::new();
337 {
338 let mut guard = registry.entries.write().unwrap();
339 for (name, service) in self.entries {
340 guard.insert(
341 name,
342 RegistryEntry {
343 service,
344 status: ServiceStatus::Unknown,
345 },
346 );
347 }
348 }
349 registry
350 }
351}
352
353// ─── Global singleton ─────────────────────────────────────────────────────────
354
355/// Process-wide default service registry singleton.
356///
357/// Initialized once via [`LazyLock`]. Use for global lookup of well-known
358/// services without passing the registry through call chains.
359///
360/// Register your services into the global registry at startup:
361///
362/// ```no_run
363/// use stygian_graph::application::registry::global_registry;
364/// use stygian_graph::adapters::noop::NoopService;
365/// use std::sync::Arc;
366///
367/// global_registry().register("noop".to_string(), Arc::new(NoopService));
368/// ```
369#[must_use]
370pub fn global_registry() -> &'static ServiceRegistry {
371 static INSTANCE: LazyLock<ServiceRegistry> = LazyLock::new(ServiceRegistry::new);
372 &INSTANCE
373}
374
375// ─── Tests ────────────────────────────────────────────────────────────────────
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::adapters::noop::NoopService as NoopScraper;
381
382 fn noop() -> Arc<dyn ScrapingService> {
383 Arc::new(NoopScraper)
384 }
385
386 #[test]
387 fn register_and_get() {
388 let r = ServiceRegistry::new();
389 r.register("svc".to_string(), noop());
390 assert!(r.get("svc").is_some());
391 assert!(r.get("missing").is_none());
392 }
393
394 #[test]
395 fn deregister() {
396 let r = ServiceRegistry::new();
397 r.register("svc".to_string(), noop());
398 assert!(r.deregister("svc"));
399 assert!(!r.deregister("svc")); // idempotent
400 assert!(r.get("svc").is_none());
401 }
402
403 #[test]
404 fn names_lists_all() {
405 let r = ServiceRegistry::builder()
406 .register("a", noop())
407 .register("b", noop())
408 .build();
409 let mut names = r.names();
410 names.sort();
411 assert_eq!(names, vec!["a", "b"]);
412 }
413
414 #[test]
415 fn builder_pattern() {
416 let r = ServiceRegistry::builder()
417 .register("one", noop())
418 .register("two", noop())
419 .build();
420 assert_eq!(r.names().len(), 2);
421 }
422
423 #[test]
424 fn status_unknown_after_register() {
425 let r = ServiceRegistry::new();
426 r.register("svc".to_string(), noop());
427 assert_eq!(r.status("svc"), Some(ServiceStatus::Unknown));
428 }
429
430 #[test]
431 fn update_status() {
432 let r = ServiceRegistry::new();
433 r.register("svc".to_string(), noop());
434 r.update_status("svc", ServiceStatus::Healthy);
435 assert_eq!(r.status("svc"), Some(ServiceStatus::Healthy));
436 }
437
438 #[test]
439 fn service_status_is_available() {
440 assert!(ServiceStatus::Healthy.is_available());
441 assert!(ServiceStatus::Degraded("x".into()).is_available());
442 assert!(!ServiceStatus::Unavailable("x".into()).is_available());
443 assert!(!ServiceStatus::Unknown.is_available());
444 }
445
446 #[tokio::test]
447 async fn health_check_all_marks_healthy() {
448 let r = ServiceRegistry::builder().register("noop", noop()).build();
449 let results = r.health_check_all().await;
450 assert_eq!(results.get("noop"), Some(&ServiceStatus::Healthy));
451 // Stored status updated
452 assert_eq!(r.status("noop"), Some(ServiceStatus::Healthy));
453 }
454
455 #[test]
456 fn global_registry_singleton_is_same_ref() {
457 use std::ptr;
458 let a = global_registry();
459 let b = global_registry();
460 assert!(ptr::addr_eq(a, b));
461 }
462}