Skip to main content

clawft_kernel/
mesh_service.rs

1//! Cross-node service registry query protocol (K6.3).
2//!
3//! Defines request/response types for querying a remote node's
4//! ServiceRegistry. Uses the ServiceApi pattern per K5 Symposium D13.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::mesh_clock::{Clock, MonoTime, RealClock};
12
13/// Request to resolve a service on a remote node.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ServiceResolveRequest {
16    /// Name of the service to resolve.
17    pub service_name: String,
18    /// Requesting node ID.
19    pub requesting_node: String,
20}
21
22/// Response to a service resolution request.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ServiceResolveResponse {
25    /// Whether the service was found.
26    pub found: bool,
27    /// Service endpoint info (if found).
28    pub endpoint: Option<RemoteServiceEndpoint>,
29    /// Error message (if not found).
30    pub error: Option<String>,
31}
32
33/// Information about a remotely available service.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RemoteServiceEndpoint {
36    /// Service name.
37    pub name: String,
38    /// Node hosting the service.
39    pub node_id: String,
40    /// PID of the process hosting this service.
41    pub pid: u64,
42    /// Available methods.
43    pub methods: Vec<String>,
44    /// Service version.
45    pub version: String,
46    /// Service contract hash (if chain-anchored).
47    pub contract_hash: Option<String>,
48    /// Additional metadata.
49    pub metadata: HashMap<String, String>,
50}
51
52/// Wire / API alias for a remote service endpoint (WEFT-115).
53///
54/// Historical audit name was `ServiceEndpoint`; production code uses
55/// [`RemoteServiceEndpoint`]. Both names refer to the same type.
56pub type ServiceEndpoint = RemoteServiceEndpoint;
57
58/// Cached resolution of a remote service.
59#[derive(Debug, Clone)]
60pub struct ResolvedService {
61    /// Resolved endpoint.
62    pub endpoint: RemoteServiceEndpoint,
63    /// When this resolution was cached (monotonic).
64    pub resolved_at: MonoTime,
65    /// Cache TTL.
66    pub ttl: Duration,
67}
68
69impl ResolvedService {
70    /// Check if this cached resolution has expired under `clock`.
71    pub fn is_expired(&self, clock: &dyn Clock) -> bool {
72        clock.now().elapsed_since(self.resolved_at) > self.ttl
73    }
74}
75
76/// Service resolution cache for remote services.
77pub struct ServiceResolutionCache {
78    /// Cached resolutions keyed by service name.
79    cache: HashMap<String, ResolvedService>,
80    /// Negative cache for known-missing services.
81    negative_cache: HashMap<String, MonoTime>,
82    /// TTL for negative cache entries.
83    negative_ttl: Duration,
84    /// Injectable clock (WEFT-113).
85    clock: Arc<dyn Clock>,
86    /// Optional chain manager for exochain audit logging.
87    #[cfg(feature = "exochain")]
88    chain_manager: Option<Arc<crate::chain::ChainManager>>,
89}
90
91impl ServiceResolutionCache {
92    /// Create a new empty cache with a 30-second negative TTL.
93    ///
94    /// Binds the production [`RealClock`].
95    pub fn new() -> Self {
96        Self::with_clock(RealClock::shared())
97    }
98
99    /// Create a cache with an injectable [`Clock`].
100    pub fn with_clock(clock: Arc<dyn Clock>) -> Self {
101        Self {
102            cache: HashMap::new(),
103            negative_cache: HashMap::new(),
104            negative_ttl: Duration::from_secs(30),
105            clock,
106            #[cfg(feature = "exochain")]
107            chain_manager: None,
108        }
109    }
110
111    /// Attach a chain manager for exochain audit logging.
112    #[cfg(feature = "exochain")]
113    pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
114        self.chain_manager = Some(cm);
115    }
116
117    /// Look up a cached service resolution (returns `None` if expired).
118    pub fn get(&self, service_name: &str) -> Option<&ResolvedService> {
119        self.cache
120            .get(service_name)
121            .filter(|r| !r.is_expired(self.clock.as_ref()))
122    }
123
124    /// Check if a service is known-missing (negative cached).
125    pub fn is_known_missing(&self, service_name: &str) -> bool {
126        self.negative_cache.get(service_name).is_some_and(|t| {
127            self.clock.now().elapsed_since(*t) < self.negative_ttl
128        })
129    }
130
131    /// Cache a positive resolution.
132    pub fn insert(&mut self, service_name: String, resolved: ResolvedService) {
133        #[cfg(feature = "exochain")]
134        if let Some(ref cm) = self.chain_manager {
135            cm.append(
136                "mesh_service",
137                crate::chain::EVENT_KIND_MESH_SERVICE_REGISTER,
138                Some(serde_json::json!({
139                    "service_name": &service_name,
140                    "node_id": &resolved.endpoint.node_id,
141                    "version": &resolved.endpoint.version,
142                })),
143            );
144        }
145        self.negative_cache.remove(&service_name);
146        self.cache.insert(service_name, resolved);
147    }
148
149    /// Cache a negative (not found) result.
150    pub fn insert_negative(&mut self, service_name: String) {
151        #[cfg(feature = "exochain")]
152        if let Some(ref cm) = self.chain_manager {
153            cm.append(
154                "mesh_service",
155                crate::chain::EVENT_KIND_MESH_SERVICE_DEREGISTER,
156                Some(serde_json::json!({
157                    "service_name": &service_name,
158                    "action": "negative_cache",
159                })),
160            );
161        }
162        self.negative_cache
163            .insert(service_name, self.clock.now());
164    }
165
166    /// Evict all expired entries from both positive and negative caches.
167    pub fn evict_expired(&mut self) {
168        let clock = self.clock.as_ref();
169        self.cache.retain(|_, r| !r.is_expired(clock));
170        let now = clock.now();
171        let neg_ttl = self.negative_ttl;
172        self.negative_cache
173            .retain(|_, t| now.elapsed_since(*t) < neg_ttl);
174    }
175
176    /// Number of cached positive resolutions (including expired).
177    pub fn len(&self) -> usize {
178        self.cache.len()
179    }
180
181    /// Whether the positive cache is empty.
182    pub fn is_empty(&self) -> bool {
183        self.cache.is_empty()
184    }
185}
186
187impl Default for ServiceResolutionCache {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193/// RegistryQueryService exposes local service resolution via ServiceApi pattern.
194/// Registered as "registry" service, queryable by remote nodes.
195pub struct RegistryQueryService {
196    /// Methods exposed by this service.
197    methods: Vec<String>,
198}
199
200impl RegistryQueryService {
201    /// Create a new registry query service with default methods.
202    pub fn new() -> Self {
203        Self {
204            methods: vec!["resolve".into(), "list".into(), "health".into()],
205        }
206    }
207
208    /// Handle a service query request, dispatching by method name.
209    pub fn handle_query(&self, method: &str, params: &serde_json::Value) -> serde_json::Value {
210        match method {
211            "resolve" => {
212                let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
213                serde_json::json!({
214                    "method": "resolve",
215                    "name": name,
216                    "status": "query_dispatched"
217                })
218            }
219            "list" => {
220                serde_json::json!({
221                    "method": "list",
222                    "status": "query_dispatched"
223                })
224            }
225            "health" => {
226                let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
227                serde_json::json!({
228                    "method": "health",
229                    "name": name,
230                    "status": "query_dispatched"
231                })
232            }
233            _ => serde_json::json!({"error": format!("unknown method: {method}")}),
234        }
235    }
236
237    /// Get the list of exposed methods.
238    pub fn methods(&self) -> &[String] {
239        &self.methods
240    }
241}
242
243impl Default for RegistryQueryService {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249/// Circuit breaker state for a remote node or service.
250///
251/// Implements the standard closed/open/half-open pattern to prevent
252/// cascading failures when a remote node becomes unreachable.
253#[non_exhaustive]
254#[derive(Debug, Clone)]
255pub enum CircuitState {
256    /// Normal operation -- requests flow through.
257    Closed { error_count: u32, threshold: u32 },
258    /// Too many failures -- requests blocked for cooldown.
259    Open {
260        opened_at: MonoTime,
261        cooldown: Duration,
262        threshold: u32,
263    },
264    /// Testing if service has recovered (one probe allowed).
265    HalfOpen { threshold: u32 },
266}
267
268impl CircuitState {
269    /// Create a new circuit breaker in the closed state.
270    pub fn new(threshold: u32) -> Self {
271        Self::Closed {
272            error_count: 0,
273            threshold,
274        }
275    }
276
277    /// Record a successful call. Resets to closed state.
278    pub fn record_success(&mut self) {
279        let t = self.threshold();
280        *self = Self::Closed {
281            error_count: 0,
282            threshold: t,
283        };
284    }
285
286    /// Record a failed call. Returns `true` if the circuit just opened.
287    ///
288    /// Uses the production [`RealClock`]. Prefer
289    /// [`Self::record_failure_at`] when a [`Clock`] is available.
290    pub fn record_failure(&mut self) -> bool {
291        self.record_failure_at(&RealClock)
292    }
293
294    /// Record a failure at `clock.now()`.
295    pub fn record_failure_at(&mut self, clock: &dyn Clock) -> bool {
296        match self {
297            Self::Closed {
298                error_count,
299                threshold,
300            } => {
301                *error_count += 1;
302                if *error_count >= *threshold {
303                    let t = *threshold;
304                    *self = Self::Open {
305                        opened_at: clock.now(),
306                        cooldown: Duration::from_secs(30),
307                        threshold: t,
308                    };
309                    return true;
310                }
311                false
312            }
313            Self::HalfOpen { threshold } => {
314                let t = *threshold;
315                *self = Self::Open {
316                    opened_at: clock.now(),
317                    cooldown: Duration::from_secs(30),
318                    threshold: t,
319                };
320                true
321            }
322            Self::Open { .. } => false,
323        }
324    }
325
326    /// Check if requests should be allowed through.
327    ///
328    /// Uses the production [`RealClock`]. Prefer
329    /// [`Self::is_allowed_at`] when a [`Clock`] is available.
330    pub fn is_allowed(&mut self) -> bool {
331        self.is_allowed_at(&RealClock)
332    }
333
334    /// Check allowance using an injectable [`Clock`].
335    ///
336    /// - **Closed**: always allowed.
337    /// - **Open**: allowed only after cooldown expires (transitions to half-open).
338    /// - **HalfOpen**: allowed (probe request).
339    pub fn is_allowed_at(&mut self, clock: &dyn Clock) -> bool {
340        match self {
341            Self::Closed { .. } => true,
342            Self::Open {
343                opened_at,
344                cooldown,
345                threshold,
346            } => {
347                if clock.now().elapsed_since(*opened_at) > *cooldown {
348                    let t = *threshold;
349                    *self = Self::HalfOpen { threshold: t };
350                    true
351                } else {
352                    false
353                }
354            }
355            Self::HalfOpen { .. } => true,
356        }
357    }
358
359    /// Get the failure threshold.
360    fn threshold(&self) -> u32 {
361        match self {
362            Self::Closed { threshold, .. } => *threshold,
363            Self::Open { threshold, .. } => *threshold,
364            Self::HalfOpen { threshold } => *threshold,
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::mesh_clock::MockClock;
373
374    fn make_endpoint(name: &str) -> RemoteServiceEndpoint {
375        RemoteServiceEndpoint {
376            name: name.to_string(),
377            node_id: "node-1".to_string(),
378            pid: 42,
379            methods: vec!["ping".to_string()],
380            version: "1.0.0".to_string(),
381            contract_hash: None,
382            metadata: HashMap::new(),
383        }
384    }
385
386    fn make_resolved(name: &str, ttl_secs: u64, clock: &dyn Clock) -> ResolvedService {
387        ResolvedService {
388            endpoint: make_endpoint(name),
389            resolved_at: clock.now(),
390            ttl: Duration::from_secs(ttl_secs),
391        }
392    }
393
394    #[test]
395    fn cache_insert_and_get() {
396        let mut cache = ServiceResolutionCache::new();
397        cache.insert("auth".to_string(), make_resolved("auth", 60, &RealClock));
398        assert!(cache.get("auth").is_some());
399        assert_eq!(cache.get("auth").unwrap().endpoint.name, "auth");
400    }
401
402    #[test]
403    fn cache_expired_returns_none() {
404        let clock = MockClock::new();
405        let mut cache = ServiceResolutionCache::with_clock(Arc::new(clock.clone()));
406        cache.insert("old".to_string(), make_resolved("old", 1, &clock));
407        clock.advance(Duration::from_secs(2));
408        assert!(cache.get("old").is_none());
409    }
410
411    #[test]
412    fn cache_missing_key_returns_none() {
413        let cache = ServiceResolutionCache::new();
414        assert!(cache.get("nope").is_none());
415    }
416
417    #[test]
418    fn negative_cache() {
419        let mut cache = ServiceResolutionCache::new();
420        assert!(!cache.is_known_missing("gone"));
421        cache.insert_negative("gone".to_string());
422        assert!(cache.is_known_missing("gone"));
423    }
424
425    #[test]
426    fn positive_insert_clears_negative() {
427        let mut cache = ServiceResolutionCache::new();
428        cache.insert_negative("svc".to_string());
429        assert!(cache.is_known_missing("svc"));
430        cache.insert("svc".to_string(), make_resolved("svc", 60, &RealClock));
431        assert!(!cache.is_known_missing("svc"));
432        assert!(cache.get("svc").is_some());
433    }
434
435    #[test]
436    fn evict_expired_removes_stale() {
437        let clock = MockClock::new();
438        let mut cache = ServiceResolutionCache::with_clock(Arc::new(clock.clone()));
439        cache.insert("stale".to_string(), make_resolved("stale", 1, &clock));
440        clock.advance(Duration::from_secs(2));
441        cache.insert("fresh".to_string(), make_resolved("fresh", 300, &clock));
442        cache.evict_expired();
443        assert_eq!(cache.len(), 1);
444        assert!(cache.get("fresh").is_some());
445    }
446
447    #[test]
448    fn resolve_request_serde_roundtrip() {
449        let req = ServiceResolveRequest {
450            service_name: "auth".to_string(),
451            requesting_node: "node-5".to_string(),
452        };
453        let json = serde_json::to_string(&req).unwrap();
454        let restored: ServiceResolveRequest = serde_json::from_str(&json).unwrap();
455        assert_eq!(restored.service_name, "auth");
456        assert_eq!(restored.requesting_node, "node-5");
457    }
458
459    #[test]
460    fn resolve_response_found_serde_roundtrip() {
461        let resp = ServiceResolveResponse {
462            found: true,
463            endpoint: Some(make_endpoint("auth")),
464            error: None,
465        };
466        let json = serde_json::to_string(&resp).unwrap();
467        let restored: ServiceResolveResponse = serde_json::from_str(&json).unwrap();
468        assert!(restored.found);
469        assert_eq!(restored.endpoint.unwrap().name, "auth");
470    }
471
472    #[test]
473    fn resolve_response_not_found_serde_roundtrip() {
474        let resp = ServiceResolveResponse {
475            found: false,
476            endpoint: None,
477            error: Some("service not registered".to_string()),
478        };
479        let json = serde_json::to_string(&resp).unwrap();
480        let restored: ServiceResolveResponse = serde_json::from_str(&json).unwrap();
481        assert!(!restored.found);
482        assert!(restored.endpoint.is_none());
483        assert_eq!(restored.error.unwrap(), "service not registered");
484    }
485
486    #[test]
487    fn resolved_service_expiry() {
488        let clock = MockClock::new();
489        let fresh = make_resolved("x", 60, &clock);
490        assert!(!fresh.is_expired(&clock));
491
492        let stale = make_resolved("y", 1, &clock);
493        clock.advance(Duration::from_secs(2));
494        assert!(stale.is_expired(&clock));
495    }
496
497    #[test]
498    fn empty_cache() {
499        let cache = ServiceResolutionCache::new();
500        assert!(cache.is_empty());
501        assert_eq!(cache.len(), 0);
502    }
503
504    // ── RegistryQueryService tests ──────────────────────────────────
505
506    #[test]
507    fn registry_query_service_methods() {
508        let svc = RegistryQueryService::new();
509        let methods = svc.methods();
510        assert_eq!(methods.len(), 3);
511        assert!(methods.contains(&"resolve".to_string()));
512        assert!(methods.contains(&"list".to_string()));
513        assert!(methods.contains(&"health".to_string()));
514    }
515
516    #[test]
517    fn registry_query_resolve() {
518        let svc = RegistryQueryService::new();
519        let params = serde_json::json!({"name": "auth"});
520        let result = svc.handle_query("resolve", &params);
521        assert_eq!(result["method"], "resolve");
522        assert_eq!(result["name"], "auth");
523        assert_eq!(result["status"], "query_dispatched");
524    }
525
526    #[test]
527    fn registry_query_list() {
528        let svc = RegistryQueryService::new();
529        let result = svc.handle_query("list", &serde_json::json!({}));
530        assert_eq!(result["method"], "list");
531        assert_eq!(result["status"], "query_dispatched");
532    }
533
534    #[test]
535    fn registry_query_health() {
536        let svc = RegistryQueryService::new();
537        let params = serde_json::json!({"name": "db"});
538        let result = svc.handle_query("health", &params);
539        assert_eq!(result["method"], "health");
540        assert_eq!(result["name"], "db");
541    }
542
543    #[test]
544    fn registry_query_unknown_method() {
545        let svc = RegistryQueryService::new();
546        let result = svc.handle_query("unknown", &serde_json::json!({}));
547        assert!(result.get("error").is_some());
548    }
549
550    #[test]
551    fn registry_query_default() {
552        let svc = RegistryQueryService::default();
553        assert_eq!(svc.methods().len(), 3);
554    }
555
556    // ── CircuitState tests ──────────────────────────────────────────
557
558    #[test]
559    fn circuit_starts_closed() {
560        let cb = CircuitState::new(3);
561        assert!(matches!(
562            cb,
563            CircuitState::Closed {
564                error_count: 0,
565                threshold: 3
566            }
567        ));
568    }
569
570    #[test]
571    fn circuit_stays_closed_below_threshold() {
572        let mut cb = CircuitState::new(3);
573        assert!(!cb.record_failure()); // 1
574        assert!(!cb.record_failure()); // 2
575        assert!(cb.is_allowed());
576    }
577
578    #[test]
579    fn circuit_opens_at_threshold() {
580        let mut cb = CircuitState::new(3);
581        assert!(!cb.record_failure()); // 1
582        assert!(!cb.record_failure()); // 2
583        assert!(cb.record_failure()); // 3 -> opens
584        assert!(matches!(cb, CircuitState::Open { .. }));
585        assert!(!cb.is_allowed()); // cooldown not elapsed
586    }
587
588    #[test]
589    fn circuit_success_resets_to_closed() {
590        let mut cb = CircuitState::new(3);
591        cb.record_failure();
592        cb.record_failure();
593        cb.record_success();
594        assert!(matches!(cb, CircuitState::Closed { error_count: 0, .. }));
595        assert!(cb.is_allowed());
596    }
597
598    #[test]
599    fn circuit_open_transitions_to_half_open_after_cooldown() {
600        let clock = MockClock::new();
601        let mut cb = CircuitState::Open {
602            opened_at: clock.now(),
603            cooldown: Duration::from_secs(30),
604            threshold: 3,
605        };
606        // Cooldown not elapsed yet.
607        assert!(!cb.is_allowed_at(&clock));
608        clock.advance(Duration::from_secs(31));
609        // Cooldown has elapsed, should transition to half-open
610        assert!(cb.is_allowed_at(&clock));
611        assert!(matches!(cb, CircuitState::HalfOpen { .. }));
612    }
613
614    #[test]
615    fn circuit_half_open_success_closes() {
616        let mut cb = CircuitState::HalfOpen { threshold: 3 };
617        assert!(cb.is_allowed());
618        cb.record_success();
619        assert!(matches!(cb, CircuitState::Closed { error_count: 0, .. }));
620    }
621
622    #[test]
623    fn circuit_half_open_failure_reopens() {
624        let mut cb = CircuitState::HalfOpen { threshold: 3 };
625        assert!(cb.record_failure()); // reopens
626        assert!(matches!(cb, CircuitState::Open { .. }));
627        assert!(!cb.is_allowed()); // fresh cooldown
628    }
629}