Skip to main content

clawft_kernel/
mesh_service_adv.rs

1//! Service advertisement for mesh networking (K6.5).
2//!
3//! Enables cluster-wide service discovery by advertising services
4//! to the mesh and resolving services across nodes.
5
6use std::collections::HashMap;
7#[cfg(feature = "exochain")]
8use std::sync::Arc;
9
10use serde::{Deserialize, Serialize};
11
12use crate::ipc::GlobalPid;
13
14/// Service advertised to the cluster.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ServiceAdvertisement {
17    /// Service name (must be unique within the cluster).
18    pub name: String,
19    /// Available methods.
20    pub methods: Vec<String>,
21    /// Node hosting this service.
22    pub node_id: String,
23    /// Process hosting this service.
24    pub global_pid: GlobalPid,
25    /// Service version.
26    pub version: String,
27    /// Contract hash (if chain-anchored).
28    pub contract_hash: Option<String>,
29    /// Additional metadata.
30    pub metadata: HashMap<String, String>,
31    /// When this advertisement was last updated.
32    pub last_updated: u64,
33}
34
35/// Cluster-wide service registry built from advertisements.
36pub struct ClusterServiceRegistry {
37    /// Services keyed by name. Multiple nodes may advertise the same service.
38    services: HashMap<String, Vec<ServiceAdvertisement>>,
39    /// Optional chain manager for exochain audit logging.
40    #[cfg(feature = "exochain")]
41    chain_manager: Option<Arc<crate::chain::ChainManager>>,
42}
43
44impl ClusterServiceRegistry {
45    pub fn new() -> Self {
46        Self {
47            services: HashMap::new(),
48            #[cfg(feature = "exochain")]
49            chain_manager: None,
50        }
51    }
52
53    /// Attach a chain manager for exochain audit logging.
54    #[cfg(feature = "exochain")]
55    pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
56        self.chain_manager = Some(cm);
57    }
58
59    /// Merge a service advertisement (add or update).
60    pub fn merge(&mut self, advert: ServiceAdvertisement) {
61        #[cfg(feature = "exochain")]
62        if let Some(ref cm) = self.chain_manager {
63            cm.append(
64                "mesh_service",
65                crate::chain::EVENT_KIND_MESH_SERVICE_REGISTER,
66                Some(serde_json::json!({
67                    "service_name": &advert.name,
68                    "node_id": &advert.node_id,
69                    "version": &advert.version,
70                    "action": "merge",
71                })),
72            );
73        }
74        let entries = self.services.entry(advert.name.clone()).or_default();
75        // Update existing entry for same node, or add new.
76        if let Some(existing) = entries.iter_mut().find(|e| e.node_id == advert.node_id) {
77            if advert.last_updated > existing.last_updated {
78                *existing = advert;
79            }
80        } else {
81            entries.push(advert);
82        }
83    }
84
85    /// Resolve a service by name. Returns all nodes hosting it.
86    pub fn resolve(&self, name: &str) -> Vec<&ServiceAdvertisement> {
87        self.services
88            .get(name)
89            .map(|entries| entries.iter().collect())
90            .unwrap_or_default()
91    }
92
93    /// Resolve a service, preferring a specific node.
94    pub fn resolve_preferred(
95        &self,
96        name: &str,
97        preferred_node: &str,
98    ) -> Option<&ServiceAdvertisement> {
99        let entries = self.services.get(name)?;
100        entries
101            .iter()
102            .find(|e| e.node_id == preferred_node)
103            .or_else(|| entries.first())
104    }
105
106    /// List all known service names.
107    pub fn service_names(&self) -> Vec<String> {
108        self.services.keys().cloned().collect()
109    }
110
111    /// Remove all services from a node.
112    pub fn remove_node(&mut self, node_id: &str) {
113        #[cfg(feature = "exochain")]
114        if let Some(ref cm) = self.chain_manager {
115            cm.append(
116                "mesh_service",
117                crate::chain::EVENT_KIND_MESH_SERVICE_DEREGISTER,
118                Some(serde_json::json!({
119                    "node_id": node_id,
120                    "action": "remove_node",
121                })),
122            );
123        }
124        for entries in self.services.values_mut() {
125            entries.retain(|e| e.node_id != node_id);
126        }
127        self.services.retain(|_, entries| !entries.is_empty());
128    }
129
130    /// Get all advertisements (for gossip).
131    pub fn all_advertisements(&self) -> Vec<&ServiceAdvertisement> {
132        self.services.values().flat_map(|v| v.iter()).collect()
133    }
134
135    /// Number of unique services.
136    pub fn service_count(&self) -> usize {
137        self.services.len()
138    }
139
140    /// Total number of advertisements (across all replicas).
141    pub fn total_advertisements(&self) -> usize {
142        self.services.values().map(|v| v.len()).sum()
143    }
144
145    /// Resolve a service using round-robin across replicas.
146    ///
147    /// The caller provides a shared atomic counter that is incremented on
148    /// each call, distributing requests evenly across all nodes hosting
149    /// the named service.
150    pub fn resolve_round_robin(
151        &self,
152        name: &str,
153        counter: &std::sync::atomic::AtomicU64,
154    ) -> Option<&ServiceAdvertisement> {
155        let entries = self.services.get(name)?;
156        if entries.is_empty() {
157            return None;
158        }
159        let idx =
160            counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize % entries.len();
161        entries.get(idx)
162    }
163}
164
165impl Default for ClusterServiceRegistry {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn make_svc_advert(name: &str, node: &str, pid: u64, ts: u64) -> ServiceAdvertisement {
176        ServiceAdvertisement {
177            name: name.to_string(),
178            methods: vec!["get".to_string(), "set".to_string()],
179            node_id: node.to_string(),
180            global_pid: GlobalPid::local(pid, node),
181            version: "1.0.0".to_string(),
182            contract_hash: None,
183            metadata: HashMap::new(),
184            last_updated: ts,
185        }
186    }
187
188    #[test]
189    fn service_advertisement_serde_roundtrip() {
190        let advert = make_svc_advert("cache", "node-a", 1, 100);
191        let json = serde_json::to_string(&advert).unwrap();
192        let restored: ServiceAdvertisement = serde_json::from_str(&json).unwrap();
193        assert_eq!(restored.name, "cache");
194        assert_eq!(restored.node_id, "node-a");
195        assert_eq!(restored.version, "1.0.0");
196    }
197
198    #[test]
199    fn merge_and_resolve() {
200        let mut registry = ClusterServiceRegistry::new();
201        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
202
203        let results = registry.resolve("cache");
204        assert_eq!(results.len(), 1);
205        assert_eq!(results[0].node_id, "node-a");
206    }
207
208    #[test]
209    fn resolve_preferred_finds_preferred() {
210        let mut registry = ClusterServiceRegistry::new();
211        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
212        registry.merge(make_svc_advert("cache", "node-b", 2, 100));
213
214        let result = registry.resolve_preferred("cache", "node-b").unwrap();
215        assert_eq!(result.node_id, "node-b");
216    }
217
218    #[test]
219    fn resolve_preferred_falls_back() {
220        let mut registry = ClusterServiceRegistry::new();
221        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
222
223        // Preferred node not found, falls back to first
224        let result = registry.resolve_preferred("cache", "node-z").unwrap();
225        assert_eq!(result.node_id, "node-a");
226    }
227
228    #[test]
229    fn resolve_preferred_missing_service() {
230        let registry = ClusterServiceRegistry::new();
231        assert!(registry.resolve_preferred("missing", "node-a").is_none());
232    }
233
234    #[test]
235    fn remove_node_removes_all_services() {
236        let mut registry = ClusterServiceRegistry::new();
237        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
238        registry.merge(make_svc_advert("auth", "node-a", 2, 100));
239        registry.merge(make_svc_advert("cache", "node-b", 3, 100));
240
241        registry.remove_node("node-a");
242
243        assert_eq!(registry.service_count(), 1); // only cache on node-b remains
244        assert!(registry.resolve("auth").is_empty());
245        assert_eq!(registry.resolve("cache").len(), 1);
246    }
247
248    #[test]
249    fn multiple_replicas_same_service() {
250        let mut registry = ClusterServiceRegistry::new();
251        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
252        registry.merge(make_svc_advert("cache", "node-b", 2, 100));
253        registry.merge(make_svc_advert("cache", "node-c", 3, 100));
254
255        assert_eq!(registry.service_count(), 1);
256        assert_eq!(registry.total_advertisements(), 3);
257        assert_eq!(registry.resolve("cache").len(), 3);
258    }
259
260    #[test]
261    fn service_names_lists_all() {
262        let mut registry = ClusterServiceRegistry::new();
263        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
264        registry.merge(make_svc_advert("auth", "node-a", 2, 100));
265        registry.merge(make_svc_advert("search", "node-b", 3, 100));
266
267        let mut names = registry.service_names();
268        names.sort();
269        assert_eq!(names, vec!["auth", "cache", "search"]);
270    }
271
272    #[test]
273    fn merge_updates_existing_node_entry() {
274        let mut registry = ClusterServiceRegistry::new();
275        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
276
277        // Update with newer timestamp
278        let mut updated = make_svc_advert("cache", "node-a", 1, 200);
279        updated.version = "2.0.0".to_string();
280        registry.merge(updated);
281
282        // Should still be one entry, but updated
283        assert_eq!(registry.total_advertisements(), 1);
284        let results = registry.resolve("cache");
285        assert_eq!(results[0].version, "2.0.0");
286    }
287
288    #[test]
289    fn resolve_round_robin_distributes() {
290        let mut registry = ClusterServiceRegistry::new();
291        registry.merge(make_svc_advert("cache", "node-a", 1, 100));
292        registry.merge(make_svc_advert("cache", "node-b", 2, 100));
293        registry.merge(make_svc_advert("cache", "node-c", 3, 100));
294
295        let counter = std::sync::atomic::AtomicU64::new(0);
296
297        // Three calls should hit all three nodes in order
298        let r0 = registry.resolve_round_robin("cache", &counter).unwrap();
299        let r1 = registry.resolve_round_robin("cache", &counter).unwrap();
300        let r2 = registry.resolve_round_robin("cache", &counter).unwrap();
301
302        // All three nodes must be visited
303        let mut nodes: Vec<&str> = vec![&r0.node_id, &r1.node_id, &r2.node_id];
304        nodes.sort();
305        assert_eq!(nodes, vec!["node-a", "node-b", "node-c"]);
306
307        // Fourth call wraps around to first node
308        let r3 = registry.resolve_round_robin("cache", &counter).unwrap();
309        assert_eq!(r3.node_id, r0.node_id);
310    }
311
312    #[test]
313    fn resolve_round_robin_missing_service() {
314        let registry = ClusterServiceRegistry::new();
315        let counter = std::sync::atomic::AtomicU64::new(0);
316        assert!(registry.resolve_round_robin("missing", &counter).is_none());
317    }
318
319    #[test]
320    fn merge_ignores_older_update() {
321        let mut registry = ClusterServiceRegistry::new();
322        registry.merge(make_svc_advert("cache", "node-a", 1, 200));
323
324        let mut older = make_svc_advert("cache", "node-a", 1, 100);
325        older.version = "0.1.0".to_string();
326        registry.merge(older);
327
328        let results = registry.resolve("cache");
329        assert_eq!(results[0].version, "1.0.0"); // unchanged
330    }
331}