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