Skip to main content

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}